first committ, still fighting with... a lot! 😅
Some checks are pending
CI / Format (push) Waiting to run
CI / Docs (push) Waiting to run
CI / Clippy lints (push) Waiting to run
CI / Bevy lints (push) Waiting to run
CI / Tests (push) Waiting to run
CI / Check web (push) Waiting to run

This commit is contained in:
Roberto Maurizzi 2025-07-06 22:14:17 +08:00
commit 495556500f
Signed by: robm
GPG key ID: F26E59AFAAADEA55
74 changed files with 18135 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 experimental 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

15
.editorconfig Normal file
View file

@ -0,0 +1,15 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[.github/workflows/*.{yaml,yml}]
indent_size = 2

180
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,180 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
cancel-in-progress: true
env:
# Reduce compile time and cache size.
RUSTFLAGS: -Dwarnings -Zshare-generics=y -Zthreads=0
RUSTDOCFLAGS: -Dwarnings -Zshare-generics=y -Zthreads=0
# Use the same Rust toolchain across jobs so they can share a cache.
toolchain: nightly-2025-04-03
jobs:
# Check formatting.
format:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.toolchain }}
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
# Check documentation.
docs:
name: Docs
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.toolchain }}
- name: Restore Rust cache
id: cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ci
save-if: false
- name: Install build dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Check documentation
run: cargo doc --locked --workspace --profile ci --all-features --document-private-items --no-deps
# Run Clippy lints.
clippy-lints:
name: Clippy lints
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.toolchain }}
components: clippy
- name: Restore Rust cache
id: cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ci
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install build dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Run Clippy lints
run: cargo clippy --locked --workspace --all-targets --profile ci --all-features
# Run Bevy lints.
bevy-lints:
name: Bevy lints
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain (plus bevy_lint)
uses: TheBevyFlock/bevy_cli/bevy_lint@lint-v0.3.0
- name: Restore Rust cache
id: cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ci
save-if: false
- name: Install build dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Run Bevy lints
run: bevy_lint --locked --workspace --all-targets --profile ci --all-features
# Run tests.
tests:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up environment
run: echo "RUSTFLAGS=${RUSTFLAGS:+$RUSTFLAGS }-Zcodegen-backend=cranelift" >> "${GITHUB_ENV}"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.toolchain }}
components: rustc-codegen-cranelift-preview
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: test
cache-directories: ${{ env.LD_LIBRARY_PATH }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install build dependencies
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Run tests
run: cargo test --locked --workspace --all-targets --profile ci --no-fail-fast
# Check that the web build compiles.
check-web:
name: Check web
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.toolchain }}
targets: wasm32-unknown-unknown
- name: Restore Rust cache
id: cache
uses: Swatinem/rust-cache@v2
with:
shared-key: web-ci
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install build dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev
- name: Check web
run: cargo check --config 'profile.web.inherits="dev"' --profile ci --no-default-features --features dev --target wasm32-unknown-unknown

335
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,335 @@
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
concurrency:
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
cancel-in-progress: true
# Configure the release workflow by editing the following values.
env:
# The base filename of the binary produced by `cargo build`.
cargo_build_binary_name: bevy-jam-6
# The path to the assets directory.
assets_path: assets
# Whether to build and package a release for a given target platform.
build_for_web: true
build_for_linux: true
build_for_windows: true
build_for_macos: true
# 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: RobertoMaurizzi/chain-reaction-collapse
############
# 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: roberto-maurizzi.bevy-jam-6
# 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-jam-6
# The name of the `.zip` or `.dmg` file produced by this workflow.
# Defaults to `app_binary_name` if commented out.
#app_package_name: bevy-jam-6
# 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 Jam 6
# 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 Jam 6
# 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
# Enabling this only helps with consecutive releases to the same tag (and takes up cache storage space).
# See: <https://github.com/orgs/community/discussions/27059>.
use_github_cache: false
# Reduce compile time.
RUSTFLAGS: -Dwarnings -Zshare-generics=y -Zthreads=0
jobs:
# Forward some environment variables as outputs of this job.
# 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>
forward-env:
runs-on: ubuntu-latest
steps:
- name: Do nothing
run: "true"
outputs:
upload_to_itch: ${{ env.upload_to_itch }}
# Determine the version number for this workflow.
get-version:
runs-on: ubuntu-latest
steps:
- name: Determine version number
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 }}
# Avoid rate-limiting. See: <https://github.com/cargo-bins/cargo-binstall/issues/2045>.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
strategy:
matrix:
include:
- platform: web
targets: wasm32-unknown-unknown
package_ext: .zip
runner: ubuntu-latest
- platform: linux
targets: x86_64-unknown-linux-gnu
package_ext: .zip
runner: ubuntu-latest
- platform: windows
targets: x86_64-pc-windows-msvc
binary_ext: .exe
package_ext: .zip
runner: windows-latest
- platform: macos
targets: x86_64-apple-darwin aarch64-apple-darwin
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
# Check if building for this platform is enabled.
echo 'is_platform_enabled=${{
(matrix.platform == 'web' && env.build_for_web == 'true') ||
(matrix.platform == 'linux' && env.build_for_linux == 'true') ||
(matrix.platform == 'windows' && env.build_for_windows == 'true') ||
(matrix.platform == 'macos' && env.build_for_macos == 'true')
}}' >> "${GITHUB_ENV}"
- name: Checkout repository
if: ${{ env.is_platform_enabled == 'true' }}
uses: actions/checkout@v4
with:
lfs: ${{ env.git_lfs }}
- name: Install Rust toolchain
if: ${{ env.is_platform_enabled == 'true' }}
uses: dtolnay/rust-toolchain@nightly
with:
targets: ${{ matrix.targets }}
- name: Restore Rust cache
if: ${{ env.is_platform_enabled == 'true' && env.use_github_cache == 'true' }}
uses: Swatinem/rust-cache@v2
with:
shared-key: release
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install build dependencies (Linux)
if: ${{ env.is_platform_enabled == 'true' && 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
if: ${{ env.is_platform_enabled == 'true' }}
run: rm -rf tmp; mkdir -p tmp/binary '${{ env.app }}'
- name: Install cargo-binstall
if: ${{ env.is_platform_enabled == 'true' }}
uses: cargo-bins/cargo-binstall@main
- name: Install Bevy CLI
if: ${{ env.is_platform_enabled == 'true' }}
run: cargo binstall --locked --no-confirm --force --git='https://github.com/TheBevyFlock/bevy_cli' bevy_cli
- name: Build and add web bundle to app (Web)
if: ${{ env.is_platform_enabled == 'true' && matrix.platform == 'web' }}
run: |
cargo binstall --locked --no-confirm --force wasm-bindgen-cli
cargo binstall --locked --no-confirm --force wasm-opt
bevy build --locked --release --features='${{ matrix.features }}' --yes web --bundle
mv 'target/bevy_web/web-release/${{ env.cargo_build_binary_name }}' '${{ env.app }}'
- name: Build and add binaries to app (non-Web)
if: ${{ env.is_platform_enabled == 'true' && matrix.platform != 'web' }}
run: |
for target in ${{ matrix.targets }}; do
bevy build --locked --release --target="${target}" --features='${{ matrix.features }}'
mv target/"${target}"/release/'${{ env.cargo_build_binary_name }}${{ matrix.binary_ext }}' tmp/binary/"${target}"'${{ matrix.binary_ext }}'
done
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: ${{ env.is_platform_enabled == 'true' && 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: ${{ env.is_platform_enabled == 'true' && 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: ${{ env.is_platform_enabled == 'true' && 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: ${{ env.is_platform_enabled == 'true' && 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
if: ${{ env.is_platform_enabled == 'true' }}
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.is_platform_enabled == 'true' && 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
# Upload all packages to itch.io.
upload-to-itch:
runs-on: ubuntu-latest
needs:
- forward-env
- get-version
- build
if: ${{ needs.forward-env.outputs.upload_to_itch != '' }}
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

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View file

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Native Debug" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<!-- We use -\-no-default-features here because otherwise debugging will fail with dynamic linking.-->
<option name="command" value="run --no-default-features" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
</method>
</configuration>
</component>

View file

@ -0,0 +1,19 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Native dev" type="ShConfigurationType" folderName="Build">
<option name="SCRIPT_TEXT" value="bevy run" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs>
<env name="RUST_BACKTRACE" value="full" />
</envs>
<method v="2" />
</configuration>
</component>

View file

@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Native release" type="ShConfigurationType" folderName="Build">
<option name="SCRIPT_TEXT" value="bevy run --release" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs />
<method v="2" />
</configuration>
</component>

19
.idea/runConfigurations/Run_Web_dev.xml generated Normal file
View file

@ -0,0 +1,19 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Web dev" type="ShConfigurationType" folderName="Build">
<option name="SCRIPT_TEXT" value="bevy run --yes web" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs>
<env name="RUST_BACKTRACE" value="full" />
</envs>
<method v="2" />
</configuration>
</component>

View file

@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Web release" type="ShConfigurationType" folderName="Build">
<option name="SCRIPT_TEXT" value="bevy run --yes --release web" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs />
<method v="2" />
</configuration>
</component>

68
.vscode/bevy.code-snippets vendored Normal file
View file

@ -0,0 +1,68 @@
{
"Bevy: New top-level function Plugin": {
"scope": "rust",
"prefix": "plugin",
"body": [
"use bevy::prelude::*;",
"",
"pub(super) fn plugin(app: &mut App) {",
"\t$0",
"}"
]
},
"Bevy: New Component": {
"scope": "rust",
"prefix": "component",
"body": [
"#[derive(Component, Reflect, Debug)]",
"#[reflect(Component)]",
"struct $1;"
]
},
"Bevy: New Resource": {
"scope": "rust",
"prefix": "resource",
"body": [
"#[derive(Resource, Reflect, Debug, Default)]",
"#[reflect(Resource)]",
"struct $1;"
]
},
"Bevy: New Event": {
"scope": "rust",
"prefix": "event",
"body": [
"#[derive(Event, Debug)]",
"struct $1;"
]
},
"Bevy: New SystemSet": {
"scope": "rust",
"prefix": "systemset",
"body": [
"#[derive(SystemSet, Copy, Clone, Eq, PartialEq, Hash, Debug)]",
"enum $1 {",
"\t$0",
"}"
]
},
"Bevy: New Schedule": {
"scope": "rust",
"prefix": "schedule",
"body": [
"#[derive(ScheduleLabel, Copy, Clone, Eq, PartialEq, Hash, Debug)]",
"struct $1;"
]
},
"Bevy: New States": {
"scope": "rust",
"prefix": "states",
"body": [
"#[derive(States, Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]",
"enum $1 {",
"\t#[default]",
"\t$0",
"}"
]
}
}

9
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"recommendations": [
"fill-labs.dependi",
"editorconfig.editorconfig",
"tamasfe.even-better-toml",
"rust-lang.rust-analyzer",
"a5huynh.vscode-ron"
]
}

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
// Allow `rust-analyzer` and `cargo` to run simultaneously.
// This uses extra storage space, so consider commenting it out.
"rust-analyzer.cargo.targetDir": true,
// Display the directory of `mod.rs` files in the tab above the text editor.
"workbench.editor.customLabels.patterns": {
"**/mod.rs": "${dirname}/mod.rs"
},
}

84
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,84 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run native dev",
"type": "process",
"command": "bevy",
"args": [
"run"
],
"options": {
"env": {
"RUST_BACKTRACE": "full"
}
},
"presentation": {
"clear": true
},
"problemMatcher": [
"$rustc"
],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Run native release",
"type": "process",
"command": "bevy",
"args": [
"run",
"--release"
],
"presentation": {
"clear": true
},
"problemMatcher": [
"$rustc"
],
"group": "build"
},
{
"label": "Run web dev",
"type": "process",
"command": "bevy",
"args": [
"run",
"--yes",
"web"
],
"options": {
"env": {
"RUST_BACKTRACE": "full"
}
},
"presentation": {
"clear": true
},
"problemMatcher": [
"$rustc"
],
"group": "build"
},
{
"label": "Run web release",
"type": "process",
"command": "bevy",
"args": [
"run",
"--yes",
"--release",
"web"
],
"presentation": {
"clear": true
},
"problemMatcher": [
"$rustc"
],
"group": "build"
}
]
}

6352
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

129
Cargo.toml Normal file
View file

@ -0,0 +1,129 @@
[package]
name = "chain-reaction-collapse"
authors = ["RobertoMaurizzi <roberto.maurizzi@gmail.com>"]
version = "0.1.0"
edition = "2024"
[dependencies]
bevy = { version = "0.16", 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",
] }
bevy_ecs_ldtk = "0.12.0"
bevy-inspector-egui = { version = "0.31.0", optional = true }
# Your web builds will start failing if you add a dependency that pulls in `getrandom` v0.3+.
# To fix this, you should tell `getrandom` to use the `wasm_js` backend on Wasm.
# See: <https://docs.rs/getrandom/0.3.3/getrandom/#webassembly-support>.
#[target.wasm32-unknown-unknown.dependencies]
#getrandom = { version = "0.3", features = ["wasm_js"] }
# In addition to enabling the `wasm_js` feature, you need to include `--cfg 'getrandom_backend="wasm_js"'`
# in your rustflags for both local and CI/CD web builds, taking into account that rustflags specified in
# multiple places are NOT combined (see <https://github.com/rust-lang/cargo/issues/5376>).
# Alternatively, you can opt out of the rustflags check with this patch:
#[patch.crates-io]
#getrandom = { git = "https://github.com/benfrankel/getrandom" }
[features]
# Default to a native dev build.
default = ["dev_native"]
dev = [
# Improve compile times for dev builds by linking Bevy as a dynamic library.
"bevy/dynamic_linking",
"bevy/bevy_dev_tools",
"bevy/bevy_ui_debug",
# Improve error messages coming from Bevy
"bevy/track_location",
]
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",
]
inspector = ["bevy-inspector-egui"]
[package.metadata.bevy_cli.release]
# Disable dev features for release builds.
default-features = false
[package.metadata.bevy_cli.web]
# Disable native features for web builds.
default-features = false
[package.metadata.bevy_cli.web.dev]
features = ["dev"]
[lints.rust]
# Mark `bevy_lint` as a valid `cfg`, as it is set when the Bevy linter runs.
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(bevy_lint)"] }
[lints.clippy]
# Bevy supplies arguments to systems via dependency injection, so it's natural for systems to
# request more than 7 arguments, which would undesirably trigger this lint.
too_many_arguments = "allow"
# Queries may access many components, which would undesirably trigger this lint.
type_complexity = "allow"
# Make sure macros use their standard braces, such as `[]` for `bevy_ecs::children!`.
nonstandard_macro_braces = "warn"
# You can configure the warning levels of Bevy lints here. For a list of all lints, see:
# <https://thebevyflock.github.io/bevy_cli/bevy_lint/lints/>
[package.metadata.bevy_lint]
# panicking_methods = "deny"
# pedantic = "warn"
# 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
[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"
# This profile will be used by `bevy run web` automatically.
[profile.web-release]
# Default to release profile values.
inherits = "release"
# 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"
# Optimize for build time in CI.
[profile.ci]
inherits = "dev"
opt-level = 0
debug = "line-tables-only"
codegen-units = 4
[profile.ci.package."*"]
opt-level = 0

4
README.md Normal file
View file

@ -0,0 +1,4 @@
# Chain Reaction Collapse
This project was generated using the [Bevy New 2D](https://github.com/TheBevyFlock/bevy_new_2d) template.
Check out the [documentation](https://github.com/TheBevyFlock/bevy_new_2d/blob/main/README.md) to get started!

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
assets/images/ducky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

BIN
assets/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

5695
assets/levels/world.ldtk Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,222 @@
{
"colors": {
"name": "Liberated Palette",
"authors": [
"Eliza Wyatt (DeathsDarling)"
],
"urls": [
"https://github.com/ElizaWy/LPC/wiki/Style-Guide#color-palette"
],
"licenses": [
"OGA-BY 3.0"
],
"notes": [
"Original palette made for the LPC set, by Eliza Wyatt."
]
},
"anatomy.body.male": {
"notes": [
"see details at https://opengameart.org/content/lpc-character-bases",
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"bluecarrot16",
"Benjamin K. Smith (BenCreating)",
"Evert",
"Eliza Wyatt (ElizaWy)",
"TheraHedwig",
"MuffinElZangano",
"Durrani",
"Johannes Sjölund (wulax)",
"Stephen Challener (Redshrike)"
],
"licenses": [
"CC-BY-SA 3.0",
"GPL 3.0"
],
"urls": [
"https://opengameart.org/content/lpc-jump-expanded",
"https://opengameart.org/content/lpc-revised-character-basics",
"https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles",
"https://opengameart.org/content/lpc-be-seated",
"https://opengameart.org/content/lpc-runcycle-for-male-muscular-and-pregnant-character-bases-with-modular-heads",
"https://opengameart.org/content/lpc-medieval-fantasy-character-sprites",
"https://opengameart.org/content/lpc-character-bases",
"https://opengameart.org/content/lpc-male-jumping-animation-by-durrani",
"https://opengameart.org/content/lpc-runcycle-and-diagonal-walkcycle"
],
"name": "Male"
},
"anatomy.head.human_male": {
"notes": [
"original head by Redshrike",
"tweaks by BenCreating",
"modular version by bluecarrot16",
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"bluecarrot16",
"Benjamin K. Smith (BenCreating)",
"Stephen Challener (Redshrike)"
],
"licenses": [
"OGA-BY 3.0",
"CC-BY-SA 3.0",
"GPL 3.0"
],
"urls": [
"https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles",
"https://opengameart.org/content/lpc-character-bases"
],
"name": "Human Male"
},
"anatomy.nose.straight": {
"notes": [
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"Thane Brimhall (pennomi)",
"Matthew Krohn (makrohn)"
],
"licenses": [
"GPL 3.0",
"CC-BY-SA 3.0"
],
"urls": [
"https://opengameart.org/content/lpc-base-character-expressions"
],
"name": "Straight"
},
"anatomy.hair.messy3": {
"notes": [
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"Fabzy",
"bluecarrot16"
],
"licenses": [
"CC-BY-SA 3.0"
],
"urls": [
"https://opengameart.org/content/lpc-hair",
"https://opengameart.org/content/the-revolution-hair"
],
"name": "Messy3"
},
"clothes.torso.longsleeve": {
"notes": [
"original by ElizaWy",
"edited to v3 bases by bluecarrot16",
"original by wulax",
"recolors and cleanup by JaidynReiman",
"further recolors by bluecarrot16",
"edited to pregnant v3 bases by bluecarrot16",
"teen body by Redshrike",
"teen shirt by ElizaWy derived from base",
"teen edited by bluecarrot16 to v3 bases",
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"bluecarrot16",
"ElizaWy",
"Stephen Challener (Redshrike)",
"David Conway Jr. (JaidynReiman)",
"Johannes Sjölund (wulax)"
],
"licenses": [
"OGA-BY 3.0",
"CC-BY-SA 3.0",
"GPL 3.0"
],
"urls": [
"https://opengameart.org/content/lpc-7-womens-shirts",
"https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles",
"https://opengameart.org/content/lpc-medieval-fantasy-character-sprites",
"http://opengameart.org/content/lpc-clothing-updates"
],
"name": "Longsleeve"
},
"clothes.torso3.collared": {
"notes": [
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"bluecarrot16"
],
"licenses": [
"OGA-BY 3.0",
"GPL 3.0"
],
"urls": [
"https://opengameart.org/content/lpc-gentleman",
"https://opengameart.org/content/lpc-pirates"
],
"name": "Collared"
},
"clothes.feet.shoes": {
"notes": [
"original by wulax",
"edited for female base by Joe White",
"edited for v3 base by bluecarrot16",
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"Joe White",
"Johannes Sjölund (wulax)"
],
"licenses": [
"CC-BY-SA 3.0",
"GPL 3.0"
],
"urls": [
"https://opengameart.org/content/lpc-medieval-fantasy-character-sprites",
"http://opengameart.org/content/lpc-clothing-updates"
],
"name": "Shoes"
},
"clothes.legs.pants": {
"notes": [
"original male pants by wulax",
"edited for female by Joe White",
"recolors by JaidynReiman",
"walkcycle adapted to pregnant base by ElizaWy",
"remaining animations, recolors and edits to v3 base by bluecarrot16",
"teem body by Redshrike",
"teen legs by ElizaWy derived from base",
"2023-12 [napsio]: minimized colors to 6 for each material and fixed broken colors"
],
"authors": [
"napsio",
"bluecarrot16",
"ElizaWy",
"David Conway Jr. (JaidynReiman)",
"Joe White",
"Matthew Krohn (makrohn)",
"Johannes Sjölund (wulax)",
"Nila122",
"Stephen Challener (Redshrike)"
],
"licenses": [
"CC-BY-SA 3.0",
"GPL 3.0"
],
"urls": [
"https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles",
"https://opengameart.org/content/lpc-medieval-fantasy-character-sprites",
"http://opengameart.org/content/lpc-clothing-updates",
"https://opengameart.org/content/more-lpc-clothes-and-hair",
"https://opengameart.org/content/lpc-pregnancy-bases-maternity-wear",
"https://opengameart.org/content/lpc-teen-unisex-base-clothes",
"https://opengameart.org/content/lpc-clothes-for-children"
],
"name": "Pants"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,176 @@
{
"bodyTypeName": "male",
"url": "https://liberatedpixelcup.github.io/Universal-LPC-Spritesheet-Character-Generator/#?body=Body_color_light&head=Human_male_light&clothes=Longsleeve_blue&legs=Pants_black&eye_color=Eye_Color_blue&eyebrows=Thin_Eyebrows_ginger&nose=Straight_nose_light&hair=Plain_ginger&shoes=Revised_Shoes_brown&socks=Ankle_Socks_white&jacket=Collared_coat_tan",
"spritesheets": "https://liberatedpixelcup.github.io/Universal-LPC-Spritesheet-Character-Generator/spritesheets/",
"version": 1,
"datetime": "6/2/2025, 10:57:16 AM",
"credits": [
{
"fileName": "body/bodies/male/light.png",
"licenses": "OGA-BY 3.0,CC-BY-SA 3.0,GPL 3.0",
"authors": "bluecarrot16,JaidynReiman,Benjamin K. Smith (BenCreating),Evert,Eliza Wyatt (ElizaWy),TheraHedwig,MuffinElZangano,Durrani,Johannes Sjölund (wulax),Stephen Challener (Redshrike)",
"urls": "https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles,https://opengameart.org/content/lpc-medieval-fantasy-character-sprites,https://opengameart.org/content/lpc-male-jumping-animation-by-durrani,https://opengameart.org/content/lpc-runcycle-and-diagonal-walkcycle,https://opengameart.org/content/lpc-revised-character-basics,https://opengameart.org/content/lpc-be-seated,https://opengameart.org/content/lpc-runcycle-for-male-muscular-and-pregnant-character-bases-with-modular-heads,https://opengameart.org/content/lpc-jump-expanded,https://opengameart.org/content/lpc-character-bases",
"notes": "see details at https://opengameart.org/content/lpc-character-bases; 'Thick' Male Revised Run/Climb by JaidynReiman (based on ElizaWy's LPC Revised)"
},
{
"fileName": "head/heads/human/male/light.png",
"licenses": "OGA-BY 3.0,CC-BY-SA 3.0,GPL 3.0",
"authors": "bluecarrot16,Benjamin K. Smith (BenCreating),Stephen Challener (Redshrike)",
"urls": "https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles,https://opengameart.org/content/lpc-character-bases",
"notes": "original head by Redshrike, tweaks by BenCreating, modular version by bluecarrot16"
},
{
"fileName": "eyes/human/adult/default/blue.png",
"licenses": "OGA-BY 3.0,CC-BY-SA 3.0,GPL 3.0",
"authors": "JaidynReiman,Matthew Krohn (makrohn),Stephen Challener (Redshrike)",
"urls": "https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles",
"notes": "original by Redshrike, mapped to all frames by Matthew Krohn & JaidynReiman"
},
{
"fileName": "head/nose/straight/adult/light.png",
"licenses": "GPL 3.0,CC-BY-SA 3.0",
"authors": "Thane Brimhall (pennomi),laetissima,Matthew Krohn (makrohn)",
"urls": "https://opengameart.org/content/lpc-base-character-expressions",
"notes": ""
},
{
"fileName": "eyes/eyebrows/thin/adult/ginger.png",
"licenses": "OGA-BY 3.0",
"authors": "ElizaWy",
"urls": "https://github.com/ElizaWy/LPC/tree/main/Characters/Hair,https://opengameart.org/content/lpc-expanded-sit-run-jump-more",
"notes": ""
},
{
"fileName": "hair/plain/adult/ginger.png",
"licenses": "OGA-BY 3.0,CC-BY-SA 3.0,GPL 3.0",
"authors": "JaidynReiman,Manuel Riecke (MrBeast),Joe White",
"urls": "https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles,https://opengameart.org/content/ponytail-and-plain-hairstyles,https://opengameart.org/content/lpc-expanded-hair",
"notes": ""
},
{
"fileName": "torso/clothes/longsleeve/longsleeve/male/blue.png",
"licenses": "OGA-BY 3.0,CC-BY-SA 3.0,GPL 3.0",
"authors": "JaidynReiman,Johannes Sjölund (wulax)",
"urls": "https://opengameart.org/content/lpc-medieval-fantasy-character-sprites,http://opengameart.org/content/lpc-clothing-updates",
"notes": "original by wulax, recolors and cleanup by JaidynReiman, further recolors by bluecarrot16"
},
{
"fileName": "torso/jacket/collared/male/tan.png",
"licenses": "CC-BY-SA 3.0,GPL 3.0",
"authors": "bluecarrot16",
"urls": "https://opengameart.org/content/lpc-gentleman,https://opengameart.org/content/lpc-pirates",
"notes": ""
},
{
"fileName": "legs/pants/male/black.png",
"licenses": "OGA-BY 3.0,GPL 3.0,CC-BY-SA 3.0",
"authors": "bluecarrot16,JaidynReiman,ElizaWy,Matthew Krohn (makrohn),Johannes Sjölund (wulax),Stephen Challener (Redshrike)",
"urls": "https://opengameart.org/content/liberated-pixel-cup-lpc-base-assets-sprites-map-tiles,https://opengameart.org/content/lpc-medieval-fantasy-character-sprites,https://opengameart.org/content/lpc-expanded-pants",
"notes": "original male pants by wulax, recolors and edits to v3 base by bluecarrot16, climb/jump/run/sit/emotes/revised combat by JaidynReiman based on ElizaWy's LPC Revised"
},
{
"fileName": "feet/shoes/revised/male/brown.png",
"licenses": "OGA-BY 3.0,GPL 3.0",
"authors": "JaidynReiman,ElizaWy,Bluecarrot16,Stephen Challener (Redshrike),Johannes Sjölund (wulax)",
"urls": "https://github.com/ElizaWy/LPC/tree/main/Characters/Clothing,https://opengameart.org/content/lpc-expanded-socks-shoes",
"notes": "original overalls and shoes by ElizaWy, base animations adapted from v3 overalls by bluecarrot16, shoes by JaidynReiman"
},
{
"fileName": "feet/socks/ankle/male/white.png",
"licenses": "OGA-BY 3.0,GPL 3.0",
"authors": "JaidynReiman,ElizaWy,Bluecarrot16,Stephen Challener (Redshrike),Johannes Sjölund (wulax)",
"urls": "https://github.com/ElizaWy/LPC/tree/main/Characters/Clothing,https://opengameart.org/content/lpc-expanded-socks-shoes",
"notes": "original overalls and thin socks by ElizaWy, base animations adapted from v3 overalls by bluecarrot16, socks by JaidynReiman"
}
],
"layers": [
{
"fileName": "body/bodies/male/light.png",
"zPos": 10,
"parentName": "body",
"name": "Body_color",
"variant": "light",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "feet/socks/ankle/male/white.png",
"zPos": 14,
"parentName": "socks",
"name": "Ankle_Socks",
"variant": "white",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "feet/shoes/revised/male/brown.png",
"zPos": 15,
"parentName": "shoes",
"name": "Revised_Shoes",
"variant": "brown",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "legs/pants/male/black.png",
"zPos": 20,
"parentName": "legs",
"name": "Pants",
"variant": "black",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "torso/clothes/longsleeve/longsleeve/male/blue.png",
"zPos": 35,
"parentName": "clothes",
"name": "Longsleeve",
"variant": "blue",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering"
},
{
"fileName": "torso/jacket/collared/male/tan.png",
"zPos": 55,
"parentName": "jacket",
"name": "Collared_coat",
"variant": "tan",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering"
},
{
"fileName": "head/heads/human/male/light.png",
"zPos": 100,
"parentName": "head",
"name": "Human_male",
"variant": "light",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "eyes/human/adult/default/blue.png",
"zPos": 105,
"parentName": "eye_color",
"name": "Eye_Color",
"variant": "blue",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "head/nose/straight/adult/light.png",
"zPos": 105,
"parentName": "nose",
"name": "Straight_nose",
"variant": "light",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "eyes/eyebrows/thin/adult/ginger.png",
"zPos": 106,
"parentName": "eyebrows",
"name": "Thin_Eyebrows",
"variant": "ginger",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
},
{
"fileName": "hair/plain/adult/ginger.png",
"zPos": 120,
"parentName": "hair",
"name": "Plain",
"variant": "ginger",
"supportedAnimations": "spellcast,thrust,walk,slash,shoot,hurt,watering,idle,jump,run,sit,emote,climb,combat,1h_slash,1h_backslash,1h_halfslash"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/spritesheets/rod.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

2
clippy.toml Normal file
View file

@ -0,0 +1,2 @@
# Require `bevy_ecs::children!` to use `[]` braces, instead of `()` or `{}`.
standard-macro-braces = [{ name = "children", brace = "[" }]

71
src/asset_tracking.rs Normal file
View file

@ -0,0 +1,71 @@
//! 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)]
pub 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>,
}
impl ResourceHandles {
/// Returns true if all requested [`Asset`]s have finished loading and are available as [`Resource`]s.
pub fn is_all_done(&self) -> bool {
self.waiting.is_empty()
}
}
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));
}
}
});
});
}

47
src/audio.rs Normal file
View file

@ -0,0 +1,47 @@
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.register_type::<Music>();
app.register_type::<SoundEffect>();
app.add_systems(
Update,
apply_global_volume.run_if(resource_changed::<GlobalVolume>),
);
}
/// An organizational marker component that should be added to a spawned [`AudioPlayer`] if it's in the
/// general "music" category (e.g. global background music, soundtrack).
///
/// This can then be used to query for and operate on sounds in that category.
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
pub struct Music;
/// A music audio instance.
pub fn music(handle: Handle<AudioSource>) -> impl Bundle {
(AudioPlayer(handle), PlaybackSettings::LOOP, Music)
}
/// An organizational marker component that should be added to a spawned [`AudioPlayer`] if it's in the
/// general "sound effect" category (e.g. 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.
#[derive(Component, Reflect, Default)]
#[reflect(Component)]
pub struct SoundEffect;
/// A sound effect audio instance.
pub fn sound_effect(handle: Handle<AudioSource>) -> impl Bundle {
(AudioPlayer(handle), PlaybackSettings::DESPAWN, SoundEffect)
}
/// [`GlobalVolume`] doesn't apply to already-running audio entities, so this system will update them.
fn apply_global_volume(
global_volume: Res<GlobalVolume>,
mut audio_query: Query<(&PlaybackSettings, &mut AudioSink)>,
) {
for (playback, mut sink) in &mut audio_query {
sink.set_volume(global_volume.volume * playback.volume);
}
}

203
src/demo/animation.rs Normal file
View file

@ -0,0 +1,203 @@
//! 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::{
AppSystems, PausableSystems,
audio::sound_effect,
demo::{movement::MovementController, player::PlayerAssets},
};
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(AppSystems::TickTimers),
(
update_animation_movement,
update_animation_atlas,
trigger_step_sound_effect,
)
.chain()
.run_if(resource_exists::<PlayerAssets>)
.in_set(AppSystems::Update),
)
.in_set(PausableSystems),
);
}
/// 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 Sprite)>) {
for (animation, mut sprite) in &mut query {
let Some(atlas) = sprite.texture_atlas.as_mut() else {
continue;
};
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().clone();
commands.spawn(sound_effect(random_step));
}
}
}
/// 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,
Thrusting,
Hurting,
}
impl PlayerAnimation {
/// The number of idle frames.
const IDLE_FRAMES: usize = 1;
/// The duration of each idle frame.
const IDLE_INTERVAL: Duration = Duration::from_millis(500);
/// The number of walking frames.
const WALKING_FRAMES: usize = 8;
/// The duration of each walking frame.
const WALKING_INTERVAL: Duration = Duration::from_millis(50);
const THRUST_FRAMES: usize = 8;
const THRUST_INTERVAL: Duration = Duration::from_millis(50);
const HURT_FRAMES: usize = 4;
const HURT_INTERVAL: Duration = Duration::from_millis(100);
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,
}
}
fn thrusting() -> Self {
Self {
timer: Timer::new(Self::THRUST_INTERVAL, TimerMode::Once),
frame: 0,
state: PlayerAnimationState::Thrusting,
}
}
fn hurting() -> Self {
Self {
timer: Timer::new(Self::HURT_INTERVAL, TimerMode::Once),
frame: 0,
state: PlayerAnimationState::Hurting,
}
}
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,
PlayerAnimationState::Thrusting => Self::THRUST_FRAMES,
PlayerAnimationState::Hurting => Self::HURT_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(),
PlayerAnimationState::Thrusting => *self = Self::thrusting(),
PlayerAnimationState::Hurting => *self = Self::hurting(),
}
}
}
/// 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 => 4 + self.frame,
PlayerAnimationState::Thrusting => 12 + self.frame,
PlayerAnimationState::Hurting => 20 + self.frame,
}
}
}

379
src/demo/level.rs Normal file
View file

@ -0,0 +1,379 @@
//! Spawn the main level.
use bevy::{
input::mouse::AccumulatedMouseMotion,
platform::collections::{HashMap, HashSet},
prelude::*,
window::PrimaryWindow,
};
use bevy_ecs_ldtk::{ldtk::NeighbourLevel, prelude::*};
use crate::{
asset_tracking::LoadResource, audio::music, demo::player::PlayerAssets, screens::Screen,
};
use super::player::Player;
pub(super) fn plugin(app: &mut App) {
app.add_plugins(LdtkPlugin)
.insert_resource(LevelSelection::iid("d53f9950-c640-11ed-8430-4942c04951ff"))
.insert_resource(LdtkSettings {
level_spawn_behavior: LevelSpawnBehavior::UseWorldTranslation {
load_level_neighbors: true,
},
..default()
})
.init_resource::<LevelWalls>()
.init_resource::<MultiLevelWalls>();
app.register_type::<PanLevel>();
app.insert_resource(PanLevel::default());
app.register_type::<LevelAssets>();
app.load_resource::<LevelAssets>();
app.register_type::<LevelWalls>();
app.register_type::<MultiLevelWalls>();
app.register_ldtk_int_cell::<WallBundle>(1);
app.add_systems(
Update,
(
translate_grid_coords_entities,
level_selection_follow_player,
cache_wall_locations,
pan_camera,
),
);
}
#[derive(Resource, Default, Asset, Clone, Reflect)]
#[reflect(Resource)]
struct PanLevel {
offset: Vec2,
}
#[derive(Resource, Asset, Clone, Reflect)]
#[reflect(Resource)]
pub struct LevelAssets {
#[dependency]
music: Handle<AudioSource>,
world: LdtkProjectHandle,
}
impl FromWorld for LevelAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
music: assets.load("audio/music/Fluffing A Duck.ogg"),
world: assets.load("levels/world.ldtk").into(),
// world: assets.load("levels/collectathon.ldtk").into(),
}
}
}
const GRID_SIZE: i32 = 16;
fn pan_camera(
mut pan: ResMut<PanLevel>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
mouse_motion: Res<AccumulatedMouseMotion>,
) {
let delta = mouse_motion.delta;
if mouse_buttons.pressed(MouseButton::Middle) {
pan.offset += delta;
info!("pan offset: {}", pan.offset);
}
}
fn translate_grid_coords_entities(
mut grid_coords_entities: Query<(&mut Transform, &GridCoords), Changed<GridCoords>>,
pan: Res<PanLevel>,
) {
for (mut transform, grid_coords) in grid_coords_entities.iter_mut() {
transform.translation = (
//pan.offset +
bevy_ecs_ldtk::utils::grid_coords_to_translation(*grid_coords, IVec2::splat(GRID_SIZE))
)
.extend(transform.translation.z);
}
}
#[derive(Default, Component)]
struct Wall;
#[derive(Default, Bundle, LdtkIntCell)]
struct WallBundle {
wall: Wall,
}
#[derive(Debug, Default, Eq, Hash, PartialEq, Reflect)]
pub enum Direction {
#[default]
N,
E,
S,
W,
}
impl TryFrom<&str> for Direction {
type Error = &'static str;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"n" => Ok(Direction::N),
"e" => Ok(Direction::E),
"s" => Ok(Direction::S),
"w" => Ok(Direction::W),
_ => Err("Invalid direction string"),
}
}
}
pub fn convert_neighbors(
neighbors: &[NeighbourLevel],
) -> Result<HashMap<Direction, LevelIid>, &'static str> {
info!("got neighbors: {:?}", neighbors);
let gino = neighbors
.iter()
// .map(|neighbor| {
// let direction = Direction::try_from(neighbor.dir.as_str())?;
// Ok((direction, LevelIid::from(neighbor.level_iid.clone())))
// })
.filter_map(|neighbor| {
Direction::try_from(neighbor.dir.as_str())
.ok()
.map(|dir| (dir, LevelIid::from(neighbor.level_iid.clone())))
})
.collect();
info!("converted to: {:?}", gino);
Ok(gino)
}
#[derive(Debug, Default, Resource, Reflect)]
#[reflect(Resource)]
pub struct LevelWalls {
wall_locations: HashSet<GridCoords>,
level_width: i32,
level_height: i32,
level_neighbours: HashMap<Direction, LevelIid>,
}
impl LevelWalls {
pub fn in_wall(&self, grid_coords: &GridCoords) -> bool {
grid_coords.x < 0
|| grid_coords.y < 0
|| grid_coords.x >= self.level_width
|| grid_coords.y >= self.level_height
|| self.wall_locations.contains(grid_coords)
}
pub fn debug_collisions(&self, player_pos: &GridCoords) {
info!(
"map for a level that is x: {} by y: {}",
self.level_width, self.level_height
);
for y in (0..self.level_height) {
for x in 0..self.level_width {
let coords = GridCoords::new(x, y);
if coords == *player_pos {
print!("@");
} else if self.in_wall(&coords) {
print!("X");
} else {
print!("_");
}
}
println!();
}
}
}
#[derive(Debug, Default, Resource, Reflect)]
#[reflect(Resource)]
pub struct MultiLevelWalls {
cache: HashMap<LevelIid, LevelWalls>,
}
impl MultiLevelWalls {
pub fn in_wall(&self, level: &LevelIid, grid_coords: &GridCoords) -> bool {
self.cache[level].in_wall(grid_coords)
}
pub fn debug_collisions(&self, level: &LevelIid, player_pos: &GridCoords) {
if let Some(level) = self.cache.get(level) {
level.debug_collisions(player_pos);
} else {
warn!("No walls cached for level: {:?}", level);
}
}
}
/// A system that spawns the main level.
pub fn spawn_level(
mut commands: Commands,
window: Single<&Window, With<PrimaryWindow>>,
level_assets: Res<LevelAssets>,
) {
let half_size = window.size() / 2.0;
commands.spawn((
Name::new("Ldtk level"),
LdtkWorldBundle {
ldtk_handle: level_assets.world.clone(),
transform: Transform::from_xyz(-half_size.x, half_size.y, 0.0),
..Default::default()
},
));
}
fn _old_cache_wall_locations(
level_selection: Res<LevelSelection>,
mut level_walls: ResMut<LevelWalls>,
mut level_events: EventReader<LevelEvent>,
walls: Query<&GridCoords, With<Wall>>,
ldtk_project_entities: Query<&LdtkProjectHandle>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
) -> Result {
for level_event in level_events.read() {
if let LevelEvent::Spawned(level_iid) = level_event {
let ldtk_project = ldtk_project_assets
.get(ldtk_project_entities.single()?)
.expect("LdtkProject should be loaded when level is spawned");
let level = ldtk_project
.get_raw_level_by_iid(level_iid.get())
.expect("spawned level should exist in project");
let wall_locations = walls.iter().copied().collect();
info!(
"loading level of dimension x: {} by y: {}",
level.px_wid, level.px_hei
);
let new_level_walls = LevelWalls {
wall_locations,
level_width: level.px_wid / GRID_SIZE,
level_height: level.px_hei / GRID_SIZE,
..Default::default()
};
*level_walls = new_level_walls;
info!(
"new level tile dimensions are x: {} y {}",
level_walls.level_width, level_walls.level_height
);
level_walls.debug_collisions(&GridCoords::default());
}
}
Ok(())
}
fn cache_wall_locations(
mut levels_wall_cache: ResMut<MultiLevelWalls>,
mut level_events: EventReader<LevelEvent>,
walls: Query<(&ChildOf, &GridCoords), With<Wall>>,
ldtk_project_entities: Query<&LdtkProjectHandle>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
) -> Result {
let multi_level_walls = levels_wall_cache.into_inner();
for level_event in level_events.read() {
if let LevelEvent::Spawned(level_iid) = level_event {
let ldtk_project = ldtk_project_assets
.get(ldtk_project_entities.single()?)
.expect("LdtkProject should be loaded when level is spawned");
let level = ldtk_project
.get_raw_level_by_iid(level_iid.get())
.expect("spawned level should exist in project");
let mut wall_locations = HashSet::<GridCoords>::default();
info!("current level neighbours: {:?}", level.neighbours);
if let Some(layers) = level.layer_instances.clone() {
// info!("layers: {:?}", layers);
layers.iter().for_each(|field| {
info!("Layer field: {:?}", field.identifier);
if field.identifier == "Walls" {
info!("Found walls layer: {:?}", field.int_grid_csv);
info!("Trying to format it");
// FIXME: a .rev() here? It doesn't look necessary from what gets printed
// remember to fix the supposed "map dragging" too
for y in (0..(level.px_hei / GRID_SIZE)).rev() {
for x in (0..(level.px_wid / GRID_SIZE)) {
let index = (y * level.px_wid / GRID_SIZE + x) as usize;
if let Some(value) = field.int_grid_csv.get(index) {
if *value == 1 {
print!("X");
wall_locations.insert(GridCoords::new(x, y));
} else {
print!("_");
}
}
}
println!();
}
}
});
}
// level.iter_fields().for_each(|field| {
// info!("Field: {:?}", field);
// });
// let wall_locations = walls.iter().map(|e| e.1).copied().collect();
info!(
"loading level of dimension x: {} by y: {}",
level.px_wid, level.px_hei
);
multi_level_walls.cache.insert(
level_iid.clone(), // You'll need to clone the key since HashMap takes ownership
LevelWalls {
wall_locations,
level_width: level.px_wid / GRID_SIZE,
level_height: level.px_hei / GRID_SIZE,
level_neighbours: convert_neighbors(&level.neighbours).unwrap_or_default(), // Convert neighbours to a HashMap
},
);
info!(
"new level tile dimensions are x: {} y {}",
multi_level_walls.cache[level_iid].level_width,
multi_level_walls.cache[level_iid].level_height
);
multi_level_walls.cache[level_iid].debug_collisions(&GridCoords::default());
}
}
Ok(())
}
fn level_selection_follow_player(
players: Query<&GlobalTransform, With<Player>>,
levels: Query<(&LevelIid, &GlobalTransform)>,
ldtk_projects: Query<&LdtkProjectHandle>,
ldtk_project_assets: Res<Assets<LdtkProject>>,
mut level_selection: ResMut<LevelSelection>,
) -> Result {
if let Ok(player_transform) = players.single() {
let ldtk_project = ldtk_project_assets
.get(ldtk_projects.single()?)
.expect("ldtk project should be loaded before player is spawned");
for (level_iid, level_transform) in levels.iter() {
let level = ldtk_project
.get_raw_level_by_iid(level_iid.get())
.expect("level should exist in only project");
let level_bounds = Rect {
min: Vec2::new(
level_transform.translation().x,
level_transform.translation().y,
),
max: Vec2::new(
level_transform.translation().x + level.px_wid as f32,
level_transform.translation().y + level.px_hei as f32,
),
};
if level_bounds.contains(player_transform.translation().truncate()) {
*level_selection = LevelSelection::Iid(level_iid.clone());
}
}
}
Ok(())
}

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,
level::plugin,
movement::plugin,
player::plugin,
));
}

133
src/demo/movement.rs Normal file
View file

@ -0,0 +1,133 @@
//! 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 bevy_ecs_ldtk::{GridCoords, LevelSelection};
use crate::{AppSystems, PausableSystems};
use super::{
level::{LevelWalls, MultiLevelWalls},
player::Player,
};
pub(super) fn plugin(app: &mut App) {
app.register_type::<MovementController>();
app.register_type::<ScreenWrap>();
app.add_systems(
Update,
(apply_movement, apply_screen_wrap)
.chain()
.in_set(AppSystems::Update)
.in_set(PausableSystems),
);
}
/// 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, Debug, 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: 40.0,
}
}
}
fn apply_movement(
_time: Res<Time>,
movement_query: Query<&MovementController, With<Player>>,
player_transform_query: Query<&Transform, With<Player>>,
mut players: Query<&mut GridCoords, With<Player>>,
level_selection: Res<LevelSelection>,
// level_walls: Res<LevelWalls>,
level_walls: Res<MultiLevelWalls>,
) {
let level_selection_iid = match level_selection.as_ref() {
LevelSelection::Iid(iid) => iid,
_ => panic!("level should be selected by iid"),
};
for controller in &movement_query {
// no velocity... for now? It'd be nice to have different speed depending on the tile type
// let velocity = controller.max_speed * controller.intent;
for mut player_grid_coords in players.iter_mut() {
let movement_direction = if controller.intent.x == 1.0 {
GridCoords::new(1, 0)
} else if controller.intent.x == -1.0 {
GridCoords::new(-1, 0)
} else if controller.intent.y == 1.0 {
GridCoords::new(0, 1)
} else if controller.intent.y == -1.0 {
GridCoords::new(0, -1)
} else if controller.intent == Vec2::ZERO {
// no movement
continue;
} else {
// unrecognized intent, log a warning
warn!("Unrecognized intent: {:?}", controller.intent);
return;
};
info!("player old coords: {:?}", player_grid_coords);
let destination = *player_grid_coords + movement_direction;
info!("commanded movement player coords: {:?}", destination);
info!("Level selection: {:?}", level_selection_iid);
if !level_walls.in_wall(level_selection_iid, &destination) {
*player_grid_coords = destination;
info!("new player grid_coords: {:?}", player_grid_coords);
info!(
"new player screen_coords: {:?}",
player_transform_query.single()
);
// transform.translation += velocity.extend(0.0) * time.delta_secs();
} else {
info!("SDENG!");
}
level_walls.debug_collisions(level_selection_iid, &player_grid_coords);
}
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct ScreenWrap;
fn apply_screen_wrap(
window: Single<&Window, With<PrimaryWindow>>,
mut wrap_query: Query<&mut Transform, With<ScreenWrap>>,
) {
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);
}
}

156
src/demo/player.rs Normal file
View file

@ -0,0 +1,156 @@
//! Player-specific behavior.
use bevy::{
image::{ImageLoaderSettings, ImageSampler},
prelude::*,
};
use bevy_ecs_ldtk::prelude::*;
use crate::{
AppSystems, PausableSystems,
asset_tracking::LoadResource,
demo::{
animation::PlayerAnimation,
movement::{MovementController, ScreenWrap},
},
};
pub(super) fn plugin(app: &mut App) {
app.register_type::<Player>();
app.register_type::<PlayerAssets>();
app.load_resource::<PlayerAssets>();
app.register_ldtk_entity::<PlayerBundle>("Player");
// Record directional input as movement controls.
app.add_systems(
Update,
record_player_directional_input
.in_set(AppSystems::RecordInput)
.in_set(PausableSystems),
);
app.add_systems(Update, process_player);
}
/// The player character.
// pub fn player(
// max_speed: f32,
// player_assets: &PlayerAssets,
// texture_atlas_layouts: &mut Assets<TextureAtlasLayout>,
// ) -> impl Bundle {
// // A texture atlas is a way to split a single image into a grid of related images.
// // You can learn more in this example: https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs
// let layout =
// TextureAtlasLayout::from_grid(UVec2::new(64, 64), 9, 54, Some(UVec2::splat(1)), None);
// let texture_atlas_layout = texture_atlas_layouts.add(layout);
// let player_animation = PlayerAnimation::new();
//
// (
// Name::new("Player"),
// Player,
// Sprite {
// image: player_assets.ducky.clone(),
// texture_atlas: Some(TextureAtlas {
// layout: texture_atlas_layout,
// index: player_animation.get_atlas_index(),
// }),
// ..default()
// },
// // Transform::from_scale(Vec2::splat(1.0).extend(1.0)),
// MovementController {
// max_speed,
// ..default()
// },
// ScreenWrap,
// player_animation,
// )
// }
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default, Reflect)]
#[reflect(Component)]
pub struct Player;
#[derive(Default, Bundle, LdtkEntity)]
struct PlayerBundle {
#[sprite_sheet]
sprite_sheet: Sprite,
#[worldly]
worldly: Worldly,
#[grid_coords]
grid_coords: GridCoords,
// non-ecsldtk-related components
player_comp: Player,
movement: MovementController,
}
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;
// TODO: check axes for gridcoords!
if input.just_pressed(KeyCode::KeyW) || input.pressed(KeyCode::ArrowUp) {
intent.y -= 1.0;
}
if input.just_pressed(KeyCode::KeyS) || input.pressed(KeyCode::ArrowDown) {
intent.y += 1.0;
}
if input.just_pressed(KeyCode::KeyA) || input.pressed(KeyCode::ArrowLeft) {
intent.x -= 1.0;
}
if input.just_pressed(KeyCode::KeyD) || input.pressed(KeyCode::ArrowRight) {
intent.x += 1.0;
}
// Normalize intent so that diagonal movement is the same speed as horizontal / vertical.
// 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, Clone, Reflect)]
#[reflect(Resource)]
pub struct PlayerAssets {
#[dependency]
ducky: Handle<Image>,
#[dependency]
pub steps: Vec<Handle<AudioSource>>,
}
impl FromWorld for PlayerAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
ducky: assets.load_with_settings(
"spritesheets/researcher.png",
|settings: &mut ImageLoaderSettings| {
// Use `nearest` image sampling to preserve pixel art style.
settings.sampler = ImageSampler::nearest();
},
),
steps: vec![
assets.load("audio/sound_effects/step1.ogg"),
assets.load("audio/sound_effects/step2.ogg"),
assets.load("audio/sound_effects/step3.ogg"),
assets.load("audio/sound_effects/step4.ogg"),
],
}
}
}
fn process_player(
mut _commands: Commands,
new_players: Query<(Entity, &GridCoords), Added<Player>>,
) {
for (player_entity, player_coords) in new_players.iter() {
info!(
"Spawned new player: {:?} at {:?}",
player_entity, player_coords
);
}
}

52
src/dev_tools.rs Normal file
View file

@ -0,0 +1,52 @@
//! Development tools for the game. This plugin is only enabled in dev builds.
use bevy::{
color::palettes::css::*,
dev_tools::states::log_transitions,
input::common_conditions::{input_just_pressed, input_toggle_active},
prelude::*,
ui::UiDebugOptions,
};
#[cfg(all(feature = "dev_native", feature = "inspector"))]
use bevy_inspector_egui::{
DefaultInspectorConfigPlugin, bevy_egui::EguiPlugin, quick::WorldInspectorPlugin,
};
use crate::screens::Screen;
const TOGGLE_KEY: KeyCode = KeyCode::Backquote;
const TOGGLE_ORIG: KeyCode = KeyCode::NumpadAdd;
pub(super) fn plugin(app: &mut App) {
// activate inspector if feature is active
#[cfg(all(feature = "dev_native", feature = "inspector"))]
app.add_plugins((
DefaultInspectorConfigPlugin,
EguiPlugin {
enable_multipass_for_primary_context: true,
},
WorldInspectorPlugin::default().run_if(input_toggle_active(false, KeyCode::Backquote)),
));
// Log `Screen` state transitions.
app.add_systems(Update, log_transitions::<Screen>);
// Toggle the debug overlay for UI.
app.add_systems(
Update,
toggle_debug_ui.run_if(input_just_pressed(TOGGLE_KEY)),
);
app.add_systems(
Update,
toggle_origin.run_if(input_just_pressed(TOGGLE_ORIG)),
);
}
fn toggle_debug_ui(mut options: ResMut<UiDebugOptions>) {
options.toggle();
}
fn toggle_origin(mut gizmos: Gizmos) {
info!("cucu?");
gizmos.cross_2d(Vec2::new(-0., 0.), 12., FUCHSIA);
}

126
src/main.rs Normal file
View file

@ -0,0 +1,126 @@
// Support configuring Bevy lints within code.
#![cfg_attr(bevy_lint, feature(register_tool), register_tool(bevy))]
// Disable console on Windows for non-dev builds.
#![cfg_attr(not(feature = "dev"), windows_subsystem = "windows")]
mod asset_tracking;
mod audio;
mod demo;
#[cfg(feature = "dev")]
mod dev_tools;
mod menus;
mod screens;
mod theme;
use bevy::{asset::AssetMetaCheck, prelude::*, window::PrimaryWindow};
fn main() -> AppExit {
App::new().add_plugins(AppPlugin).run()
}
pub struct AppPlugin;
impl Plugin for AppPlugin {
fn build(&self, app: &mut App) {
// 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: "Chain Reaction Collapse".to_string(),
fit_canvas_to_parent: true,
..default()
}
.into(),
..default()
}),
);
// Add other plugins.
app.add_plugins((
asset_tracking::plugin,
audio::plugin,
demo::plugin,
#[cfg(feature = "dev")]
dev_tools::plugin,
menus::plugin,
screens::plugin,
theme::plugin,
));
// Order new `AppSystems` variants by adding them here:
app.configure_sets(
Update,
(
AppSystems::TickTimers,
AppSystems::RecordInput,
AppSystems::Update,
)
.chain(),
);
// Set up the `Pause` state.
app.init_state::<Pause>();
app.configure_sets(Update, PausableSystems.run_if(in_state(Pause(false))));
// Spawn the main camera.
app.add_systems(Startup, spawn_camera);
}
}
/// 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 AppSystems {
/// Tick timers.
TickTimers,
/// Record player input.
RecordInput,
/// Do everything else (consider splitting this into further variants).
Update,
}
/// Whether or not the game is paused.
#[derive(States, Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
#[states(scoped_entities)]
struct Pause(pub bool);
/// A system set for systems that shouldn't run while the game is paused.
#[derive(SystemSet, Copy, Clone, Eq, PartialEq, Hash, Debug)]
struct PausableSystems;
// fn apply_screen_wrap(
// window: Single<&Window, With<PrimaryWindow>>,
// mut wrap_query: Query<&mut Transform, With<ScreenWrap>>,
// ) {
// 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);
// }
// }
fn spawn_camera(mut commands: Commands, window: Single<&Window, With<PrimaryWindow>>) {
let half_size = window.size() / 2.0;
commands.spawn((
Name::new("Camera"),
Camera2d,
Projection::Orthographic(OrthographicProjection {
scale: 0.5,
..OrthographicProjection::default_2d()
}),
// FIXME: what do I need to transform the camera for?
// Transform::from_xyz(-half_size.x, half_size.y, 0.0),
));
}

113
src/menus/credits.rs Normal file
View file

@ -0,0 +1,113 @@
//! The credits menu.
use bevy::{
ecs::spawn::SpawnIter, input::common_conditions::input_just_pressed, prelude::*, ui::Val::*,
};
use crate::{asset_tracking::LoadResource, audio::music, menus::Menu, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Credits), spawn_credits_menu);
app.add_systems(
Update,
go_back.run_if(in_state(Menu::Credits).and(input_just_pressed(KeyCode::Escape))),
);
app.register_type::<CreditsAssets>();
app.load_resource::<CreditsAssets>();
app.add_systems(OnEnter(Menu::Credits), start_credits_music);
}
fn spawn_credits_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Credits Menu"),
GlobalZIndex(2),
StateScoped(Menu::Credits),
children![
widget::header("Created by"),
created_by(),
widget::header("Assets"),
assets(),
widget::button("Back", go_back_on_click),
],
));
}
fn created_by() -> impl Bundle {
grid(vec![
["Joe Shmoe", "Implemented alligator wrestling AI"],
["Jane Doe", "Made the music for the alien invasion"],
])
}
fn assets() -> impl Bundle {
grid(vec![
["Ducky sprite", "CC0 by Caz Creates Games"],
["Button SFX", "CC0 by Jaszunio15"],
["Music", "CC BY 3.0 by Kevin MacLeod"],
[
"Bevy logo",
"All rights reserved by the Bevy Foundation, permission granted for splash screen use when unmodified",
],
])
}
fn grid(content: Vec<[&'static str; 2]>) -> impl Bundle {
(
Name::new("Grid"),
Node {
display: Display::Grid,
row_gap: Px(10.0),
column_gap: Px(30.0),
grid_template_columns: RepeatedGridTrack::px(2, 400.0),
..default()
},
Children::spawn(SpawnIter(content.into_iter().flatten().enumerate().map(
|(i, text)| {
(
widget::label(text),
Node {
justify_self: if i % 2 == 0 {
JustifySelf::End
} else {
JustifySelf::Start
},
..default()
},
)
},
))),
)
}
fn go_back_on_click(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Main);
}
fn go_back(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Main);
}
#[derive(Resource, Asset, Clone, Reflect)]
#[reflect(Resource)]
struct CreditsAssets {
#[dependency]
music: Handle<AudioSource>,
}
impl FromWorld for CreditsAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
music: assets.load("audio/music/Monkeys Spinning Monkeys.ogg"),
}
}
}
fn start_credits_music(mut commands: Commands, credits_music: Res<CreditsAssets>) {
commands.spawn((
Name::new("Credits Music"),
StateScoped(Menu::Credits),
music(credits_music.music.clone()),
));
}

55
src/menus/main.rs Normal file
View file

@ -0,0 +1,55 @@
//! The main menu (seen on the title screen).
use bevy::prelude::*;
use crate::{asset_tracking::ResourceHandles, menus::Menu, screens::Screen, theme::widget};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Main), spawn_main_menu);
}
fn spawn_main_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Main Menu"),
GlobalZIndex(2),
StateScoped(Menu::Main),
#[cfg(not(target_family = "wasm"))]
children![
widget::button("Play", enter_loading_or_gameplay_screen),
widget::button("Settings", open_settings_menu),
widget::button("Credits", open_credits_menu),
widget::button("Exit", exit_app),
],
#[cfg(target_family = "wasm")]
children![
widget::button("Play", enter_loading_or_gameplay_screen),
widget::button("Settings", open_settings_menu),
widget::button("Credits", open_credits_menu),
],
));
}
fn enter_loading_or_gameplay_screen(
_: Trigger<Pointer<Click>>,
resource_handles: Res<ResourceHandles>,
mut next_screen: ResMut<NextState<Screen>>,
) {
if resource_handles.is_all_done() {
next_screen.set(Screen::Gameplay);
} else {
next_screen.set(Screen::Loading);
}
}
fn open_settings_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Settings);
}
fn open_credits_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Credits);
}
#[cfg(not(target_family = "wasm"))]
fn exit_app(_: Trigger<Pointer<Click>>, mut app_exit: EventWriter<AppExit>) {
app_exit.write(AppExit::Success);
}

30
src/menus/mod.rs Normal file
View file

@ -0,0 +1,30 @@
//! The game's menus and transitions between them.
mod credits;
mod main;
mod pause;
mod settings;
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.init_state::<Menu>();
app.add_plugins((
credits::plugin,
main::plugin,
settings::plugin,
pause::plugin,
));
}
#[derive(States, Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
#[states(scoped_entities)]
pub enum Menu {
#[default]
None,
Main,
Credits,
Settings,
Pause,
}

43
src/menus/pause.rs Normal file
View file

@ -0,0 +1,43 @@
//! The pause menu.
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
use crate::{menus::Menu, screens::Screen, theme::widget};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Pause), spawn_pause_menu);
app.add_systems(
Update,
go_back.run_if(in_state(Menu::Pause).and(input_just_pressed(KeyCode::Escape))),
);
}
fn spawn_pause_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Pause Menu"),
GlobalZIndex(2),
StateScoped(Menu::Pause),
children![
widget::header("Game paused"),
widget::button("Continue", close_menu),
widget::button("Settings", open_settings_menu),
widget::button("Quit to title", quit_to_title),
],
));
}
fn open_settings_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Settings);
}
fn close_menu(_: Trigger<Pointer<Click>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}
fn quit_to_title(_: Trigger<Pointer<Click>>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}
fn go_back(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}

125
src/menus/settings.rs Normal file
View file

@ -0,0 +1,125 @@
//! The settings menu.
//!
//! Additional settings and accessibility options should go here.
use bevy::{audio::Volume, input::common_conditions::input_just_pressed, prelude::*, ui::Val::*};
use crate::{menus::Menu, screens::Screen, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Menu::Settings), spawn_settings_menu);
app.add_systems(
Update,
go_back.run_if(in_state(Menu::Settings).and(input_just_pressed(KeyCode::Escape))),
);
app.register_type::<GlobalVolumeLabel>();
app.add_systems(
Update,
update_global_volume_label.run_if(in_state(Menu::Settings)),
);
}
fn spawn_settings_menu(mut commands: Commands) {
commands.spawn((
widget::ui_root("Settings Menu"),
GlobalZIndex(2),
StateScoped(Menu::Settings),
children![
widget::header("Settings"),
settings_grid(),
widget::button("Back", go_back_on_click),
],
));
}
fn settings_grid() -> impl Bundle {
(
Name::new("Settings Grid"),
Node {
display: Display::Grid,
row_gap: Px(10.0),
column_gap: Px(30.0),
grid_template_columns: RepeatedGridTrack::px(2, 400.0),
..default()
},
children![
(
widget::label("Master Volume"),
Node {
justify_self: JustifySelf::End,
..default()
}
),
global_volume_widget(),
],
)
}
fn global_volume_widget() -> impl Bundle {
(
Name::new("Global Volume Widget"),
Node {
justify_self: JustifySelf::Start,
..default()
},
children![
widget::button_small("-", lower_global_volume),
(
Name::new("Current Volume"),
Node {
padding: UiRect::horizontal(Px(10.0)),
justify_content: JustifyContent::Center,
..default()
},
children![(widget::label(""), GlobalVolumeLabel)],
),
widget::button_small("+", raise_global_volume),
],
)
}
const MIN_VOLUME: f32 = 0.0;
const MAX_VOLUME: f32 = 3.0;
fn lower_global_volume(_: Trigger<Pointer<Click>>, mut global_volume: ResMut<GlobalVolume>) {
let linear = (global_volume.volume.to_linear() - 0.1).max(MIN_VOLUME);
global_volume.volume = Volume::Linear(linear);
}
fn raise_global_volume(_: Trigger<Pointer<Click>>, mut global_volume: ResMut<GlobalVolume>) {
let linear = (global_volume.volume.to_linear() + 0.1).min(MAX_VOLUME);
global_volume.volume = Volume::Linear(linear);
}
#[derive(Component, Reflect)]
#[reflect(Component)]
struct GlobalVolumeLabel;
fn update_global_volume_label(
global_volume: Res<GlobalVolume>,
mut label: Single<&mut Text, With<GlobalVolumeLabel>>,
) {
let percent = 100.0 * global_volume.volume.to_linear();
label.0 = format!("{percent:3.0}%");
}
fn go_back_on_click(
_: Trigger<Pointer<Click>>,
screen: Res<State<Screen>>,
mut next_menu: ResMut<NextState<Menu>>,
) {
next_menu.set(if screen.get() == &Screen::Title {
Menu::Main
} else {
Menu::Pause
});
}
fn go_back(screen: Res<State<Screen>>, mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(if screen.get() == &Screen::Title {
Menu::Main
} else {
Menu::Pause
});
}

61
src/screens/gameplay.rs Normal file
View file

@ -0,0 +1,61 @@
//! The screen state for the main gameplay.
use bevy::{input::common_conditions::input_just_pressed, prelude::*, ui::Val::*};
use crate::{Pause, demo::level::spawn_level, menus::Menu, screens::Screen};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Gameplay), spawn_level);
// Toggle pause on key press.
app.add_systems(
Update,
(
(pause, spawn_pause_overlay, open_pause_menu).run_if(
in_state(Screen::Gameplay)
.and(in_state(Menu::None))
.and(input_just_pressed(KeyCode::KeyP).or(input_just_pressed(KeyCode::Escape))),
),
close_menu.run_if(
in_state(Screen::Gameplay)
.and(not(in_state(Menu::None)))
.and(input_just_pressed(KeyCode::KeyP)),
),
),
);
app.add_systems(OnExit(Screen::Gameplay), (close_menu, unpause));
app.add_systems(
OnEnter(Menu::None),
unpause.run_if(in_state(Screen::Gameplay)),
);
}
fn unpause(mut next_pause: ResMut<NextState<Pause>>) {
next_pause.set(Pause(false));
}
fn pause(mut next_pause: ResMut<NextState<Pause>>) {
next_pause.set(Pause(true));
}
fn spawn_pause_overlay(mut commands: Commands) {
commands.spawn((
Name::new("Pause Overlay"),
Node {
width: Percent(100.0),
height: Percent(100.0),
..default()
},
GlobalZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
StateScoped(Pause(true)),
));
}
fn open_pause_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Pause);
}
fn close_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}

31
src/screens/loading.rs Normal file
View file

@ -0,0 +1,31 @@
//! A loading screen during which game assets are loaded if necessary.
//! This reduces stuttering, especially for audio on Wasm.
use bevy::prelude::*;
use crate::{asset_tracking::ResourceHandles, screens::Screen, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Loading), spawn_loading_screen);
app.add_systems(
Update,
enter_gameplay_screen.run_if(in_state(Screen::Loading).and(all_assets_loaded)),
);
}
fn spawn_loading_screen(mut commands: Commands) {
commands.spawn((
widget::ui_root("Loading Screen"),
StateScoped(Screen::Loading),
children![widget::label("Loading...")],
));
}
fn enter_gameplay_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Gameplay);
}
fn all_assets_loaded(resource_handles: Res<ResourceHandles>) -> bool {
resource_handles.is_all_done()
}

30
src/screens/mod.rs Normal file
View file

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

146
src/screens/splash.rs Normal file
View file

@ -0,0 +1,146 @@
//! A splash screen that plays briefly at startup.
use bevy::{
image::{ImageLoaderSettings, ImageSampler},
input::common_conditions::input_just_pressed,
prelude::*,
};
use crate::{AppSystems, screens::Screen, theme::prelude::*};
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(AppSystems::TickTimers),
apply_fade_in_out.in_set(AppSystems::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(AppSystems::TickTimers),
check_splash_timer.in_set(AppSystems::Update),
)
.run_if(in_state(Screen::Splash)),
);
// Exit the splash screen early if the player hits escape.
app.add_systems(
Update,
enter_title_screen
.run_if(input_just_pressed(KeyCode::Escape).and(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.spawn((
widget::ui_root("Splash Screen"),
BackgroundColor(SPLASH_BACKGROUND_COLOR),
StateScoped(Screen::Splash),
children![(
Name::new("Splash image"),
Node {
margin: UiRect::all(Val::Auto),
width: Val::Percent(70.0),
..default()
},
ImageNode::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();
},
)),
ImageNodeFadeInOut {
total_duration: SPLASH_DURATION_SECS,
fade_duration: SPLASH_FADE_DURATION_SECS,
t: 0.0,
},
)],
));
}
#[derive(Component, Reflect)]
#[reflect(Component)]
struct ImageNodeFadeInOut {
/// 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 ImageNodeFadeInOut {
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 ImageNodeFadeInOut>) {
for mut anim in &mut animation_query {
anim.t += time.delta_secs();
}
}
fn apply_fade_in_out(mut animation_query: Query<(&ImageNodeFadeInOut, &mut ImageNode)>) {
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::Title);
}
}
fn enter_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}

18
src/screens/title.rs Normal file
View file

@ -0,0 +1,18 @@
//! The title screen that appears after the splash screen.
use bevy::prelude::*;
use crate::{menus::Menu, screens::Screen};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Title), open_main_menu);
app.add_systems(OnExit(Screen::Title), close_menu);
}
fn open_main_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::Main);
}
fn close_menu(mut next_menu: ResMut<NextState<Menu>>) {
next_menu.set(Menu::None);
}

89
src/theme/interaction.rs Normal file
View file

@ -0,0 +1,89 @@
use bevy::prelude::*;
use crate::{asset_tracking::LoadResource, audio::sound_effect};
pub(super) fn plugin(app: &mut App) {
app.register_type::<InteractionPalette>();
app.add_systems(Update, apply_interaction_palette);
app.register_type::<InteractionAssets>();
app.load_resource::<InteractionAssets>();
app.add_observer(play_on_hover_sound_effect);
app.add_observer(play_on_click_sound_effect);
}
/// 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,
}
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, Clone, Reflect)]
#[reflect(Resource)]
struct InteractionAssets {
#[dependency]
hover: Handle<AudioSource>,
#[dependency]
click: Handle<AudioSource>,
}
impl FromWorld for InteractionAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
hover: assets.load("audio/sound_effects/button_hover.ogg"),
click: assets.load("audio/sound_effects/button_click.ogg"),
}
}
}
fn play_on_hover_sound_effect(
trigger: Trigger<Pointer<Over>>,
mut commands: Commands,
interaction_assets: Option<Res<InteractionAssets>>,
interaction_query: Query<(), With<Interaction>>,
) {
let Some(interaction_assets) = interaction_assets else {
return;
};
if interaction_query.contains(trigger.target()) {
commands.spawn(sound_effect(interaction_assets.hover.clone()));
}
}
fn play_on_click_sound_effect(
trigger: Trigger<Pointer<Click>>,
mut commands: Commands,
interaction_assets: Option<Res<InteractionAssets>>,
interaction_query: Query<(), With<Interaction>>,
) {
let Some(interaction_assets) = interaction_assets else {
return;
};
if interaction_query.contains(trigger.target()) {
commands.spawn(sound_effect(interaction_assets.click.clone()));
}
}

19
src/theme/mod.rs Normal file
View file

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

16
src/theme/palette.rs Normal file
View file

@ -0,0 +1,16 @@
use bevy::prelude::*;
/// #ddd369
pub const LABEL_TEXT: Color = Color::srgb(0.867, 0.827, 0.412);
/// #fcfbcc
pub const HEADER_TEXT: Color = Color::srgb(0.988, 0.984, 0.800);
/// #ececec
pub const BUTTON_TEXT: Color = Color::srgb(0.925, 0.925, 0.925);
/// #4666bf
pub const BUTTON_BACKGROUND: Color = Color::srgb(0.275, 0.400, 0.750);
/// #6299d1
pub const BUTTON_HOVERED_BACKGROUND: Color = Color::srgb(0.384, 0.600, 0.820);
/// #3d4999
pub const BUTTON_PRESSED_BACKGROUND: Color = Color::srgb(0.239, 0.286, 0.600);

135
src/theme/widget.rs Normal file
View file

@ -0,0 +1,135 @@
//! Helper functions for creating common widgets.
use std::borrow::Cow;
use bevy::{
ecs::{spawn::SpawnWith, system::IntoObserverSystem},
prelude::*,
ui::Val::*,
};
use crate::theme::{interaction::InteractionPalette, palette::*};
/// A root UI node that fills the window and centers its content.
pub fn ui_root(name: impl Into<Cow<'static, str>>) -> impl Bundle {
(
Name::new(name),
Node {
position_type: PositionType::Absolute,
width: Percent(100.0),
height: Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
row_gap: Px(20.0),
..default()
},
// Don't block picking events for other UI roots.
Pickable::IGNORE,
)
}
/// A simple header label. Bigger than [`label`].
pub fn header(text: impl Into<String>) -> impl Bundle {
(
Name::new("Header"),
Text(text.into()),
TextFont::from_font_size(40.0),
TextColor(HEADER_TEXT),
)
}
/// A simple text label.
pub fn label(text: impl Into<String>) -> impl Bundle {
(
Name::new("Label"),
Text(text.into()),
TextFont::from_font_size(24.0),
TextColor(LABEL_TEXT),
)
}
/// A large rounded button with text and an action defined as an [`Observer`].
pub fn button<E, B, M, I>(text: impl Into<String>, action: I) -> impl Bundle
where
E: Event,
B: Bundle,
I: IntoObserverSystem<E, B, M>,
{
button_base(
text,
action,
(
Node {
width: Px(380.0),
height: Px(80.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BorderRadius::MAX,
),
)
}
/// A small square button with text and an action defined as an [`Observer`].
pub fn button_small<E, B, M, I>(text: impl Into<String>, action: I) -> impl Bundle
where
E: Event,
B: Bundle,
I: IntoObserverSystem<E, B, M>,
{
button_base(
text,
action,
Node {
width: Px(30.0),
height: Px(30.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
)
}
/// A simple button with text and an action defined as an [`Observer`]. The button's layout is provided by `button_bundle`.
fn button_base<E, B, M, I>(
text: impl Into<String>,
action: I,
button_bundle: impl Bundle,
) -> impl Bundle
where
E: Event,
B: Bundle,
I: IntoObserverSystem<E, B, M>,
{
let text = text.into();
let action = IntoObserverSystem::into_system(action);
(
Name::new("Button"),
Node::default(),
Children::spawn(SpawnWith(|parent: &mut ChildSpawner| {
parent
.spawn((
Name::new("Button Inner"),
Button,
BackgroundColor(BUTTON_BACKGROUND),
InteractionPalette {
none: BUTTON_BACKGROUND,
hovered: BUTTON_HOVERED_BACKGROUND,
pressed: BUTTON_PRESSED_BACKGROUND,
},
children![(
Name::new("Button Text"),
Text(text),
TextFont::from_font_size(40.0),
TextColor(BUTTON_TEXT),
// Don't bubble picking events from the text up to the button.
Pickable::IGNORE,
)],
))
.insert(button_bundle)
.observe(action);
})),
)
}