mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-08 16:07:52 -06:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5deccc54a7 | ||
|
|
2455d9724b | ||
|
|
ac7c1b9ce1 | ||
|
|
d68d76c854 | ||
|
|
f1927cc67e | ||
|
|
68ab4627d8 | ||
|
|
0d8d869580 | ||
|
|
a31b85b5df | ||
|
|
21b08d4866 | ||
|
|
f075caaa17 | ||
|
|
9422168ffc | ||
|
|
35e557e298 | ||
|
|
e810419063 | ||
|
|
f7e7dee28f | ||
|
|
4b0b8f4f2e | ||
|
|
03249c88a4 | ||
|
|
2d4f97e04b | ||
|
|
317fce796c | ||
|
|
9832abd131 | ||
|
|
c94ebc6b4b | ||
|
|
8b23c1c7bd | ||
|
|
5e325a4691 |
@@ -3,10 +3,3 @@ fail-fast = false
|
|||||||
|
|
||||||
[profile.coverage]
|
[profile.coverage]
|
||||||
status-level = "none"
|
status-level = "none"
|
||||||
|
|
||||||
[[profile.default.overrides]]
|
|
||||||
filter = 'test(pacman::game::)'
|
|
||||||
test-group = 'serial'
|
|
||||||
|
|
||||||
[test-groups]
|
|
||||||
serial = { max-threads = 1 }
|
|
||||||
|
|||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,2 +1 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
scripts/* linguist-detectable=false
|
|
||||||
|
|||||||
44
.github/workflows/coverage.yaml
vendored
44
.github/workflows/coverage.yaml
vendored
@@ -4,11 +4,13 @@ on: ["push", "pull_request"]
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUST_TOOLCHAIN: nightly
|
RUST_TOOLCHAIN: 1.86.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
coverage:
|
coverage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -48,9 +50,39 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
just coverage
|
just coverage
|
||||||
|
|
||||||
- name: Coveralls upload
|
- name: Upload coverage reports to Codecov
|
||||||
uses: coverallsapp/github-action@v2
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.COVERALLS_REPO_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
path-to-lcov: lcov.info
|
files: lcov.info
|
||||||
debug: true
|
|
||||||
|
- name: Download Coveralls CLI
|
||||||
|
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
|
||||||
|
run: |
|
||||||
|
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
|
||||||
|
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin
|
||||||
|
|
||||||
|
- name: Upload coverage to Coveralls
|
||||||
|
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
|
||||||
|
run: |
|
||||||
|
if [ ! -f "lcov.info" ]; then
|
||||||
|
echo "Error: lcov.info file not found. Coverage generation may have failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for i in {1..10}; do
|
||||||
|
echo "Attempt $i: Uploading coverage to Coveralls..."
|
||||||
|
if coveralls -n report lcov.info; then
|
||||||
|
echo "Successfully uploaded coverage report."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $i -lt 10 ]; then
|
||||||
|
delay=$((2**i))
|
||||||
|
echo "Attempt $i failed. Retrying in $delay seconds..."
|
||||||
|
sleep $delay
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Failed to upload coverage report after 10 attempts."
|
||||||
|
exit 1
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,13 +14,8 @@ assets/site/build.css
|
|||||||
|
|
||||||
# Coverage reports
|
# Coverage reports
|
||||||
lcov.info
|
lcov.info
|
||||||
codecov.json
|
|
||||||
coverage.html
|
coverage.html
|
||||||
|
|
||||||
# Profiling output
|
# Profiling output
|
||||||
flamegraph.svg
|
flamegraph.svg
|
||||||
/profile.*
|
/profile.*
|
||||||
|
|
||||||
# temporary
|
|
||||||
assets/game/sound/*.wav
|
|
||||||
/*.py
|
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ repos:
|
|||||||
- id: forbid-submodules
|
- id: forbid-submodules
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
|
|
||||||
- repo: https://github.com/compilerla/conventional-pre-commit
|
|
||||||
rev: v4.2.0
|
|
||||||
hooks:
|
|
||||||
- id: conventional-pre-commit
|
|
||||||
stages: [commit-msg]
|
|
||||||
args: []
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: cargo-fmt
|
- id: cargo-fmt
|
||||||
@@ -27,31 +20,15 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
types: [rust]
|
types: [rust]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
- id: cargo-check
|
- id: cargo-check
|
||||||
name: cargo check
|
name: cargo check
|
||||||
entry: cargo check --all-targets
|
entry: cargo check --all-targets
|
||||||
language: system
|
language: system
|
||||||
types_or: [rust, cargo, cargo-lock]
|
types_or: [rust, cargo, cargo-lock]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
- id: cargo-check-wasm
|
- id: cargo-check-wasm
|
||||||
name: cargo check for wasm32-unknown-emscripten
|
name: cargo check for wasm32-unknown-emscripten
|
||||||
entry: cargo check --all-targets --target=wasm32-unknown-emscripten
|
entry: cargo check --all-targets --target=wasm32-unknown-emscripten
|
||||||
language: system
|
language: system
|
||||||
types_or: [rust, cargo, cargo-lock]
|
types_or: [rust, cargo, cargo-lock]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
- id: bump-version
|
|
||||||
name: bump version based on commit message
|
|
||||||
entry: python scripts/bump-version.py
|
|
||||||
language: system
|
|
||||||
stages: [commit-msg]
|
|
||||||
always_run: true
|
|
||||||
|
|
||||||
- id: tag-version
|
|
||||||
name: tag version based on commit message
|
|
||||||
entry: python scripts/tag-version.py
|
|
||||||
language: system
|
|
||||||
stages: [post-commit]
|
|
||||||
always_run: true
|
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pacman"
|
name = "pacman"
|
||||||
version = "0.77.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pacman"
|
name = "pacman"
|
||||||
version = "0.77.1"
|
version = "0.2.0"
|
||||||
authors = ["Xevion"]
|
authors = ["Xevion"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86.0"
|
rust-version = "1.86.0"
|
||||||
@@ -98,6 +98,3 @@ x86_64-pc-windows-msvc = { triplet = "x64-windows-static-md" }
|
|||||||
x86_64-unknown-linux-gnu = { triplet = "x64-linux" }
|
x86_64-unknown-linux-gnu = { triplet = "x64-linux" }
|
||||||
x86_64-apple-darwin = { triplet = "x64-osx" }
|
x86_64-apple-darwin = { triplet = "x64-osx" }
|
||||||
aarch64-apple-darwin = { triplet = "arm64-osx" }
|
aarch64-apple-darwin = { triplet = "arm64-osx" }
|
||||||
|
|
||||||
[lints.rust]
|
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] }
|
|
||||||
|
|||||||
14
Justfile
14
Justfile
@@ -1,6 +1,9 @@
|
|||||||
set shell := ["bash", "-c"]
|
set shell := ["bash", "-c"]
|
||||||
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||||
|
|
||||||
|
# Regex to exclude files from coverage report, double escapes for Justfile + CLI
|
||||||
|
# You can use src\\\\..., but the filename alone is acceptable too
|
||||||
|
coverage_exclude_pattern := "src\\\\app\\.rs|audio\\.rs|src\\\\error\\.rs|platform\\\\emscripten\\.rs|bin\\\\.+\\.rs|main\\.rs|platform\\\\desktop\\.rs|platform\\\\tracing_buffer\\.rs|platform\\\\buffered_writer\\.rs|systems\\\\debug\\.rs|systems\\\\profiling\\.rs"
|
||||||
|
|
||||||
binary_extension := if os() == "windows" { ".exe" } else { "" }
|
binary_extension := if os() == "windows" { ".exe" } else { "" }
|
||||||
|
|
||||||
@@ -11,19 +14,22 @@ binary_extension := if os() == "windows" { ".exe" } else { "" }
|
|||||||
html: coverage
|
html: coverage
|
||||||
cargo llvm-cov report \
|
cargo llvm-cov report \
|
||||||
--remap-path-prefix \
|
--remap-path-prefix \
|
||||||
|
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
|
||||||
--html \
|
--html \
|
||||||
--open
|
--open
|
||||||
|
|
||||||
# Display report (for humans)
|
# Display report (for humans)
|
||||||
report-coverage: coverage
|
report-coverage: coverage
|
||||||
cargo llvm-cov report --remap-path-prefix
|
cargo llvm-cov report \
|
||||||
|
--remap-path-prefix \
|
||||||
|
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
|
||||||
|
|
||||||
# Run & generate LCOV report (as base report)
|
# Run & generate report (for CI)
|
||||||
coverage:
|
coverage:
|
||||||
cargo +nightly llvm-cov \
|
cargo llvm-cov \
|
||||||
--lcov \
|
--lcov \
|
||||||
--remap-path-prefix \
|
--remap-path-prefix \
|
||||||
--workspace \
|
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
|
||||||
--output-path lcov.info \
|
--output-path lcov.info \
|
||||||
--profile coverage \
|
--profile coverage \
|
||||||
--no-fail-fast nextest
|
--no-fail-fast nextest
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# Pac-Man
|
# Pac-Man
|
||||||
|
|
||||||
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![If you're seeing this, Coveralls.io is broken again and it's not my fault.][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
|
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
|
||||||
|
|
||||||
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
|
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
|
||||||
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
|
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
|
||||||
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
|
[badge-coverage]: https://codecov.io/github/Xevion/Pac-Man/branch/master/graph/badge.svg?token=R2RBYUQK3I
|
||||||
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
|
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
|
||||||
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
|
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
|
||||||
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
|
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
|
||||||
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
||||||
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml
|
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml
|
||||||
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
|
[coverage]: https://codecov.io/github/Xevion/Pac-Man
|
||||||
[demo]: https://xevion.github.io/Pac-Man/
|
[demo]: https://xevion.github.io/Pac-Man/
|
||||||
[commits]: https://github.com/Xevion/Pac-Man/commits/master
|
[commits]: https://github.com/Xevion/Pac-Man/commits/master
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
27
bacon.toml
27
bacon.toml
@@ -28,18 +28,16 @@ need_stdout = false
|
|||||||
|
|
||||||
[jobs.test]
|
[jobs.test]
|
||||||
command = [
|
command = [
|
||||||
"cargo",
|
"cargo", "nextest", "run",
|
||||||
"nextest",
|
"--hide-progress-bar", "--failure-output", "final"
|
||||||
"run",
|
|
||||||
"--hide-progress-bar",
|
|
||||||
"--failure-output",
|
|
||||||
"final",
|
|
||||||
]
|
]
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
analyzer = "nextest"
|
analyzer = "nextest"
|
||||||
|
|
||||||
[jobs.coverage]
|
[jobs.coverage]
|
||||||
command = ["just", "report-coverage"]
|
command = [
|
||||||
|
"just", "report-coverage"
|
||||||
|
]
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
ignored_lines = [
|
ignored_lines = [
|
||||||
"info:",
|
"info:",
|
||||||
@@ -56,7 +54,7 @@ ignored_lines = [
|
|||||||
"\\s*Finished.+in \\d+",
|
"\\s*Finished.+in \\d+",
|
||||||
"\\s*Summary\\s+\\[",
|
"\\s*Summary\\s+\\[",
|
||||||
"\\s*Blocking",
|
"\\s*Blocking",
|
||||||
"Finished report saved to",
|
"Finished report saved to"
|
||||||
]
|
]
|
||||||
on_change_strategy = "wait_then_restart"
|
on_change_strategy = "wait_then_restart"
|
||||||
|
|
||||||
@@ -68,26 +66,21 @@ need_stdout = false
|
|||||||
[jobs.doc-open]
|
[jobs.doc-open]
|
||||||
command = ["cargo", "doc", "--no-deps", "--open"]
|
command = ["cargo", "doc", "--no-deps", "--open"]
|
||||||
need_stdout = false
|
need_stdout = false
|
||||||
on_success = "back" # so that we don't open the browser at each change
|
on_success = "back" # so that we don't open the browser at each change
|
||||||
|
|
||||||
[jobs.run]
|
[jobs.run]
|
||||||
command = ["cargo", "run"]
|
command = [
|
||||||
|
"cargo", "run",
|
||||||
|
]
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
allow_warnings = true
|
allow_warnings = true
|
||||||
background = false
|
background = false
|
||||||
on_change_strategy = "kill_then_restart"
|
on_change_strategy = "kill_then_restart"
|
||||||
# kill = ["pkill", "-TERM", "-P"]'
|
# kill = ["pkill", "-TERM", "-P"]'
|
||||||
|
|
||||||
[jobs.precommit]
|
|
||||||
command = ["pre-commit", "run", "--all-files"]
|
|
||||||
need_stdout = true
|
|
||||||
background = false
|
|
||||||
on_change_strategy = "kill_then_restart"
|
|
||||||
|
|
||||||
[keybindings]
|
[keybindings]
|
||||||
c = "job:clippy"
|
c = "job:clippy"
|
||||||
alt-c = "job:check"
|
alt-c = "job:check"
|
||||||
ctrl-alt-c = "job:check-all"
|
ctrl-alt-c = "job:check-all"
|
||||||
shift-c = "job:clippy-all"
|
shift-c = "job:clippy-all"
|
||||||
f = "job:coverage"
|
f = "job:coverage"
|
||||||
p = "job:precommit"
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Pre-commit hook script to automatically bump Cargo.toml version based on commit message.
|
|
||||||
|
|
||||||
This script parses the commit message for version bump keywords and uses cargo set-version
|
|
||||||
to update the version in Cargo.toml accordingly.
|
|
||||||
|
|
||||||
Supported keywords:
|
|
||||||
- "major" or "breaking": Bump major version (1.0.0 -> 2.0.0)
|
|
||||||
- "minor" or "feature": Bump minor version (1.0.0 -> 1.1.0)
|
|
||||||
- "patch" or "fix" or "bugfix": Bump patch version (1.0.0 -> 1.0.1)
|
|
||||||
|
|
||||||
Usage: python scripts/bump-version.py <commit_message_file>
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_version():
|
|
||||||
"""Get the current version from Cargo.toml."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["cargo", "metadata", "--format-version", "1", "--no-deps"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse the JSON output to get version
|
|
||||||
import json
|
|
||||||
metadata = json.loads(result.stdout)
|
|
||||||
return metadata["packages"][0]["version"]
|
|
||||||
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
|
|
||||||
print(f"Error getting current version: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bump_version(current_version, bump_type):
|
|
||||||
"""Calculate the new version based on bump type."""
|
|
||||||
try:
|
|
||||||
major, minor, patch = map(int, current_version.split('.'))
|
|
||||||
|
|
||||||
if bump_type == "major":
|
|
||||||
return f"{major + 1}.0.0"
|
|
||||||
elif bump_type == "minor":
|
|
||||||
return f"{major}.{minor + 1}.0"
|
|
||||||
elif bump_type == "patch":
|
|
||||||
return f"{major}.{minor}.{patch + 1}"
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
except ValueError:
|
|
||||||
print(f"Invalid version format: {current_version}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def set_version(new_version):
|
|
||||||
"""Set the new version using cargo set-version."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["cargo", "set-version", new_version],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
print(f"Successfully bumped version to {new_version}")
|
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error setting version: {e}", file=sys.stderr)
|
|
||||||
print(f"stdout: {e.stdout}", file=sys.stderr)
|
|
||||||
print(f"stderr: {e.stderr}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def parse_commit_message(commit_message_file):
|
|
||||||
"""Parse the commit message file for version bump keywords."""
|
|
||||||
try:
|
|
||||||
with open(commit_message_file, 'r', encoding='utf-8') as f:
|
|
||||||
message = f.read().lower()
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Commit message file not found: {commit_message_file}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading commit message: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check for version bump keywords
|
|
||||||
if re.search(r'\b(major|breaking)\b', message):
|
|
||||||
return "major"
|
|
||||||
elif re.search(r'\b(minor|feature)\b', message):
|
|
||||||
return "minor"
|
|
||||||
elif re.search(r'\b(patch|fix|bugfix)\b', message):
|
|
||||||
return "patch"
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) != 2:
|
|
||||||
print("Usage: python scripts/bump-version.py <commit_message_file>", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
commit_message_file = sys.argv[1]
|
|
||||||
|
|
||||||
# Parse commit message for version bump type
|
|
||||||
bump_type = parse_commit_message(commit_message_file)
|
|
||||||
|
|
||||||
if not bump_type:
|
|
||||||
print("No version bump keywords found in commit message")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
print(f"Found version bump type: {bump_type}")
|
|
||||||
|
|
||||||
# Get current version
|
|
||||||
current_version = get_current_version()
|
|
||||||
if not current_version:
|
|
||||||
print("Failed to get current version", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Current version: {current_version}")
|
|
||||||
|
|
||||||
# Calculate new version
|
|
||||||
new_version = bump_version(current_version, bump_type)
|
|
||||||
if not new_version:
|
|
||||||
print("Failed to calculate new version", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"New version: {new_version}")
|
|
||||||
|
|
||||||
# Set the new version
|
|
||||||
if set_version(new_version):
|
|
||||||
print("Version bump completed successfully")
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
print("Version bump failed", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Post-commit hook script to automatically create git tags based on the version in Cargo.toml.
|
|
||||||
|
|
||||||
This script reads the current version from Cargo.toml and creates a git tag with that version.
|
|
||||||
It's designed to run after the version has been bumped by the bump-version.py script.
|
|
||||||
|
|
||||||
Usage: python scripts/tag-version.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def get_version_from_cargo_toml():
|
|
||||||
"""Get the current version from Cargo.toml."""
|
|
||||||
cargo_toml_path = Path("Cargo.toml")
|
|
||||||
|
|
||||||
if not cargo_toml_path.exists():
|
|
||||||
print("Cargo.toml not found", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(cargo_toml_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Look for version = "x.y.z" pattern
|
|
||||||
version_match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
|
||||||
|
|
||||||
if version_match:
|
|
||||||
return version_match.group(1)
|
|
||||||
else:
|
|
||||||
print("Could not find version in Cargo.toml", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading Cargo.toml: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_existing_tags():
|
|
||||||
"""Get list of existing git tags."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "tag", "--list"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
return result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error getting git tags: {e}", file=sys.stderr)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def create_git_tag(version):
|
|
||||||
"""Create a git tag with the specified version."""
|
|
||||||
tag_name = f"v{version}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if tag already exists
|
|
||||||
existing_tags = get_existing_tags()
|
|
||||||
if tag_name in existing_tags:
|
|
||||||
print(f"Tag {tag_name} already exists, skipping")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Create the tag
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "tag", tag_name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"Successfully created tag: {tag_name}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error creating git tag: {e}", file=sys.stderr)
|
|
||||||
print(f"stdout: {e.stdout}", file=sys.stderr)
|
|
||||||
print(f"stderr: {e.stderr}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_git_repository():
|
|
||||||
"""Check if we're in a git repository."""
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
["git", "rev-parse", "--git-dir"],
|
|
||||||
capture_output=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Check if we're in a git repository
|
|
||||||
if not is_git_repository():
|
|
||||||
print("Not in a git repository, skipping tag creation")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Get the current version from Cargo.toml
|
|
||||||
version = get_version_from_cargo_toml()
|
|
||||||
|
|
||||||
if not version:
|
|
||||||
print("Could not determine version, skipping tag creation")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
print(f"Current version: {version}")
|
|
||||||
|
|
||||||
# Create the git tag
|
|
||||||
if create_git_tag(version):
|
|
||||||
print("Tag creation completed successfully")
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
print("Tag creation failed", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -19,8 +19,6 @@ pub enum Asset {
|
|||||||
AtlasImage,
|
AtlasImage,
|
||||||
/// Terminal Vector font for text rendering (TerminalVector.ttf)
|
/// Terminal Vector font for text rendering (TerminalVector.ttf)
|
||||||
Font,
|
Font,
|
||||||
/// Sound effect for Pac-Man's death
|
|
||||||
DeathSound,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Asset {
|
impl Asset {
|
||||||
@@ -39,7 +37,6 @@ impl Asset {
|
|||||||
Wav4 => "sound/waka/4.ogg",
|
Wav4 => "sound/waka/4.ogg",
|
||||||
AtlasImage => "atlas.png",
|
AtlasImage => "atlas.png",
|
||||||
Font => "TerminalVector.ttf",
|
Font => "TerminalVector.ttf",
|
||||||
DeathSound => "sound/pacman_death.wav",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/audio.rs
45
src/audio.rs
@@ -16,7 +16,6 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
|
|||||||
pub struct Audio {
|
pub struct Audio {
|
||||||
_mixer_context: Option<mixer::Sdl2MixerContext>,
|
_mixer_context: Option<mixer::Sdl2MixerContext>,
|
||||||
sounds: Vec<Chunk>,
|
sounds: Vec<Chunk>,
|
||||||
death_sound: Option<Chunk>,
|
|
||||||
next_sound_index: usize,
|
next_sound_index: usize,
|
||||||
muted: bool,
|
muted: bool,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
@@ -45,7 +44,6 @@ impl Audio {
|
|||||||
return Self {
|
return Self {
|
||||||
_mixer_context: None,
|
_mixer_context: None,
|
||||||
sounds: Vec::new(),
|
sounds: Vec::new(),
|
||||||
death_sound: None,
|
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -67,7 +65,6 @@ impl Audio {
|
|||||||
return Self {
|
return Self {
|
||||||
_mixer_context: None,
|
_mixer_context: None,
|
||||||
sounds: Vec::new(),
|
sounds: Vec::new(),
|
||||||
death_sound: None,
|
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -96,33 +93,12 @@ impl Audio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let death_sound = match get_asset_bytes(Asset::DeathSound) {
|
|
||||||
Ok(data) => match RWops::from_bytes(&data) {
|
|
||||||
Ok(rwops) => match rwops.load_wav() {
|
|
||||||
Ok(chunk) => Some(chunk),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to load death sound from asset API: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to create RWops for death sound: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to load death sound asset: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// If no sounds loaded successfully, disable audio
|
// If no sounds loaded successfully, disable audio
|
||||||
if sounds.is_empty() && death_sound.is_none() {
|
if sounds.is_empty() {
|
||||||
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
|
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
|
||||||
return Self {
|
return Self {
|
||||||
_mixer_context: Some(mixer_context),
|
_mixer_context: Some(mixer_context),
|
||||||
sounds: Vec::new(),
|
sounds: Vec::new(),
|
||||||
death_sound: None,
|
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -132,7 +108,6 @@ impl Audio {
|
|||||||
Audio {
|
Audio {
|
||||||
_mixer_context: Some(mixer_context),
|
_mixer_context: Some(mixer_context),
|
||||||
sounds,
|
sounds,
|
||||||
death_sound,
|
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -163,24 +138,6 @@ impl Audio {
|
|||||||
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
|
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays the death sound effect.
|
|
||||||
pub fn death(&mut self) {
|
|
||||||
if self.disabled || self.muted {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(chunk) = &self.death_sound {
|
|
||||||
mixer::Channel::all().play(chunk, 0).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Halts all currently playing audio channels.
|
|
||||||
pub fn stop_all(&mut self) {
|
|
||||||
if !self.disabled {
|
|
||||||
mixer::Channel::all().halt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Instantly mutes or unmutes all audio channels by adjusting their volume.
|
/// Instantly mutes or unmutes all audio channels by adjusting their volume.
|
||||||
///
|
///
|
||||||
/// Sets all 4 mixer channels to zero volume when muting, or restores them to
|
/// Sets all 4 mixer channels to zero volume when muting, or restores them to
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
|
||||||
#![cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use sdl2::event::Event;
|
use sdl2::event::Event;
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
|
||||||
#![cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
|
|
||||||
use circular_buffer::CircularBuffer;
|
use circular_buffer::CircularBuffer;
|
||||||
use pacman::constants::CANVAS_SIZE;
|
use pacman::constants::CANVAS_SIZE;
|
||||||
use sdl2::event::Event;
|
use sdl2::event::Event;
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
|||||||
pub mod startup {
|
pub mod startup {
|
||||||
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
|
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
|
||||||
pub const STARTUP_FRAMES: u32 = 60 * 3;
|
pub const STARTUP_FRAMES: u32 = 60 * 3;
|
||||||
|
/// Number of ticks per frame during startup
|
||||||
|
pub const STARTUP_TICKS_PER_FRAME: u32 = 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Game mechanics constants
|
/// Game mechanics constants
|
||||||
|
|||||||
195
src/game.rs
195
src/game.rs
@@ -9,26 +9,32 @@ use crate::error::{GameError, GameResult};
|
|||||||
use crate::events::GameEvent;
|
use crate::events::GameEvent;
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::map::direction::Direction;
|
use crate::map::direction::Direction;
|
||||||
|
use crate::systems::blinking::Blinking;
|
||||||
|
use crate::systems::components::{GhostAnimation, GhostState, LastAnimationState};
|
||||||
|
use crate::systems::movement::{BufferedDirection, Position, Velocity};
|
||||||
|
use crate::systems::profiling::{SystemId, Timing};
|
||||||
|
use crate::systems::render::touch_ui_render_system;
|
||||||
|
use crate::systems::render::RenderDirty;
|
||||||
use crate::systems::{
|
use crate::systems::{
|
||||||
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
|
self, combined_render_system, ghost_collision_system, present_system, Hidden, LinearAnimation, MovementModifiers, NodeId,
|
||||||
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
|
TouchState,
|
||||||
hud_render_system, item_system, linear_render_system, present_system, profile, touch_ui_render_system, AudioEvent,
|
};
|
||||||
AudioResource, AudioState, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource,
|
use crate::systems::{
|
||||||
DeltaTime, DirectionalAnimation, EntityType, Frozen, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle,
|
audio_system, blinking_system, collision_system, directional_render_system, dirty_render_system, eaten_ghost_system,
|
||||||
GhostCollider, GhostState, GlobalState, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation,
|
ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, AudioEvent,
|
||||||
MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled,
|
AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
|
||||||
PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId,
|
EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider,
|
||||||
SystemTimings, Timing, TouchState, Velocity,
|
MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence,
|
||||||
|
SystemTimings,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
||||||
use crate::texture::sprite::AtlasTile;
|
use crate::texture::sprite::AtlasTile;
|
||||||
use crate::texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite};
|
use crate::texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite};
|
||||||
use bevy_ecs::change_detection::DetectChanges;
|
|
||||||
use bevy_ecs::event::EventRegistry;
|
use bevy_ecs::event::EventRegistry;
|
||||||
use bevy_ecs::observer::Trigger;
|
use bevy_ecs::observer::Trigger;
|
||||||
use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule, SystemSet};
|
use bevy_ecs::schedule::common_conditions::resource_changed;
|
||||||
use bevy_ecs::system::{Local, Res, ResMut};
|
use bevy_ecs::schedule::{Condition, IntoScheduleConfigs, Schedule, SystemSet};
|
||||||
|
use bevy_ecs::system::{Local, ResMut};
|
||||||
use bevy_ecs::world::World;
|
use bevy_ecs::world::World;
|
||||||
use sdl2::event::EventType;
|
use sdl2::event::EventType;
|
||||||
use sdl2::image::LoadTexture;
|
use sdl2::image::LoadTexture;
|
||||||
@@ -48,9 +54,7 @@ use crate::{
|
|||||||
|
|
||||||
/// System set for all rendering systems to ensure they run after gameplay logic
|
/// System set for all rendering systems to ensure they run after gameplay logic
|
||||||
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
||||||
enum RenderSet {
|
pub struct RenderSet;
|
||||||
Animation,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Core game state manager built on the Bevy ECS architecture.
|
/// Core game state manager built on the Bevy ECS architecture.
|
||||||
///
|
///
|
||||||
@@ -108,8 +112,6 @@ impl Game {
|
|||||||
let (player_animation, player_start_sprite) = Self::create_player_animations(&atlas)?;
|
let (player_animation, player_start_sprite) = Self::create_player_animations(&atlas)?;
|
||||||
let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite);
|
let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite);
|
||||||
|
|
||||||
let death_animation = Self::create_death_animation(&atlas)?;
|
|
||||||
|
|
||||||
let mut world = World::default();
|
let mut world = World::default();
|
||||||
let mut schedule = Schedule::default();
|
let mut schedule = Schedule::default();
|
||||||
|
|
||||||
@@ -125,7 +127,6 @@ impl Game {
|
|||||||
map_texture,
|
map_texture,
|
||||||
debug_texture,
|
debug_texture,
|
||||||
ttf_atlas,
|
ttf_atlas,
|
||||||
death_animation,
|
|
||||||
)?;
|
)?;
|
||||||
Self::configure_schedule(&mut schedule);
|
Self::configure_schedule(&mut schedule);
|
||||||
|
|
||||||
@@ -309,18 +310,6 @@ impl Game {
|
|||||||
Ok((player_animation, player_start_sprite))
|
Ok((player_animation, player_start_sprite))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_death_animation(atlas: &SpriteAtlas) -> GameResult<LinearAnimation> {
|
|
||||||
let mut death_tiles = Vec::new();
|
|
||||||
for i in 0..=10 {
|
|
||||||
// Assuming death animation has 11 frames named pacman/die_0, pacman/die_1, etc.
|
|
||||||
let tile = atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Dying(i)).to_path())?;
|
|
||||||
death_tiles.push(tile);
|
|
||||||
}
|
|
||||||
|
|
||||||
let tile_sequence = TileSequence::new(&death_tiles);
|
|
||||||
Ok(LinearAnimation::new(tile_sequence, 8)) // 8 ticks per frame, non-looping
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle {
|
fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle {
|
||||||
PlayerBundle {
|
PlayerBundle {
|
||||||
player: PlayerControlled,
|
player: PlayerControlled,
|
||||||
@@ -372,19 +361,13 @@ impl Game {
|
|||||||
map_texture: sdl2::render::Texture,
|
map_texture: sdl2::render::Texture,
|
||||||
debug_texture: sdl2::render::Texture,
|
debug_texture: sdl2::render::Texture,
|
||||||
ttf_atlas: crate::texture::ttf::TtfAtlas,
|
ttf_atlas: crate::texture::ttf::TtfAtlas,
|
||||||
death_animation: LinearAnimation,
|
|
||||||
) -> GameResult<()> {
|
) -> GameResult<()> {
|
||||||
world.insert_non_send_resource(atlas);
|
world.insert_non_send_resource(atlas);
|
||||||
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
|
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
|
||||||
let player_animation = Self::create_player_animations(world.non_send_resource::<SpriteAtlas>())?.0;
|
|
||||||
world.insert_resource(PlayerAnimation(player_animation));
|
|
||||||
world.insert_resource(PlayerDeathAnimation(death_animation));
|
|
||||||
|
|
||||||
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
||||||
world.insert_resource(map);
|
world.insert_resource(map);
|
||||||
world.insert_resource(GlobalState { exit: false });
|
world.insert_resource(GlobalState { exit: false });
|
||||||
world.insert_resource(GameStage::default());
|
|
||||||
world.insert_resource(PlayerLives::default());
|
|
||||||
world.insert_resource(ScoreResource(0));
|
world.insert_resource(ScoreResource(0));
|
||||||
world.insert_resource(SystemTimings::default());
|
world.insert_resource(SystemTimings::default());
|
||||||
world.insert_resource(Timing::default());
|
world.insert_resource(Timing::default());
|
||||||
@@ -395,9 +378,10 @@ impl Game {
|
|||||||
world.insert_resource(AudioState::default());
|
world.insert_resource(AudioState::default());
|
||||||
world.insert_resource(CursorPosition::default());
|
world.insert_resource(CursorPosition::default());
|
||||||
world.insert_resource(TouchState::default());
|
world.insert_resource(TouchState::default());
|
||||||
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
|
world.insert_resource(StartupSequence::new(
|
||||||
remaining_ticks: constants::startup::STARTUP_FRAMES,
|
constants::startup::STARTUP_FRAMES,
|
||||||
}));
|
constants::startup::STARTUP_TICKS_PER_FRAME,
|
||||||
|
));
|
||||||
|
|
||||||
world.insert_non_send_resource(event_pump);
|
world.insert_non_send_resource(event_pump);
|
||||||
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
|
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
|
||||||
@@ -410,14 +394,15 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn configure_schedule(schedule: &mut Schedule) {
|
fn configure_schedule(schedule: &mut Schedule) {
|
||||||
let stage_system = profile(SystemId::Stage, systems::stage_system);
|
|
||||||
let input_system = profile(SystemId::Input, systems::input::input_system);
|
let input_system = profile(SystemId::Input, systems::input::input_system);
|
||||||
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
|
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
|
||||||
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
|
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
|
||||||
|
let startup_stage_system = profile(SystemId::Stage, systems::startup_stage_system);
|
||||||
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
|
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
|
||||||
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
|
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
|
||||||
let collision_system = profile(SystemId::Collision, collision_system);
|
let collision_system = profile(SystemId::Collision, collision_system);
|
||||||
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
|
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
|
||||||
|
|
||||||
let item_system = profile(SystemId::Item, item_system);
|
let item_system = profile(SystemId::Item, item_system);
|
||||||
let audio_system = profile(SystemId::Audio, audio_system);
|
let audio_system = profile(SystemId::Audio, audio_system);
|
||||||
let blinking_system = profile(SystemId::Blinking, blinking_system);
|
let blinking_system = profile(SystemId::Blinking, blinking_system);
|
||||||
@@ -427,55 +412,41 @@ impl Game {
|
|||||||
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
|
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
|
||||||
let present_system = profile(SystemId::Present, present_system);
|
let present_system = profile(SystemId::Present, present_system);
|
||||||
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
|
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
|
||||||
// let death_sequence_system = profile(SystemId::DeathSequence, death_sequence_system);
|
|
||||||
// let game_over_system = profile(SystemId::GameOver, systems::game_over_system);
|
|
||||||
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
|
|
||||||
|
|
||||||
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
||||||
dirty.0 = true;
|
dirty.0 = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
schedule.add_systems(
|
|
||||||
forced_dirty_system
|
|
||||||
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Input system should always run to prevent SDL event pump from blocking
|
|
||||||
let input_systems = (
|
|
||||||
input_system.run_if(|mut local: Local<u8>| {
|
|
||||||
*local = local.wrapping_add(1u8);
|
|
||||||
// run every nth frame
|
|
||||||
*local % 2 == 0
|
|
||||||
}),
|
|
||||||
player_control_system,
|
|
||||||
)
|
|
||||||
.chain();
|
|
||||||
|
|
||||||
let gameplay_systems = (
|
|
||||||
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
|
|
||||||
eaten_ghost_system,
|
|
||||||
(collision_system, ghost_collision_system, item_system).chain(),
|
|
||||||
unified_ghost_state_system,
|
|
||||||
)
|
|
||||||
.chain()
|
|
||||||
.run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
|
|
||||||
|
|
||||||
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation));
|
|
||||||
|
|
||||||
schedule.add_systems((
|
schedule.add_systems((
|
||||||
stage_system,
|
forced_dirty_system.run_if(resource_changed::<ScoreResource>.or(resource_changed::<StartupSequence>)),
|
||||||
input_systems,
|
|
||||||
gameplay_systems,
|
|
||||||
(
|
(
|
||||||
|
input_system.run_if(|mut local: Local<u8>| {
|
||||||
|
*local = local.wrapping_add(1u8);
|
||||||
|
// run every nth frame
|
||||||
|
*local % 2 == 0
|
||||||
|
}),
|
||||||
|
player_control_system,
|
||||||
|
player_movement_system,
|
||||||
|
startup_stage_system,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
player_tunnel_slowdown_system,
|
||||||
|
ghost_movement_system,
|
||||||
|
profile(SystemId::EatenGhost, eaten_ghost_system),
|
||||||
|
unified_ghost_state_system,
|
||||||
|
(collision_system, ghost_collision_system, item_system).chain(),
|
||||||
|
audio_system,
|
||||||
|
blinking_system,
|
||||||
|
(
|
||||||
|
directional_render_system,
|
||||||
|
linear_render_system,
|
||||||
dirty_render_system,
|
dirty_render_system,
|
||||||
combined_render_system,
|
combined_render_system,
|
||||||
hud_render_system,
|
hud_render_system,
|
||||||
touch_ui_render_system,
|
touch_ui_render_system,
|
||||||
present_system,
|
present_system,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain(),
|
||||||
.after(RenderSet::Animation),
|
|
||||||
audio_system,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +512,7 @@ impl Game {
|
|||||||
for (ghost_type, start_node) in ghost_start_positions {
|
for (ghost_type, start_node) in ghost_start_positions {
|
||||||
// Create the ghost bundle in a separate scope to manage borrows
|
// Create the ghost bundle in a separate scope to manage borrows
|
||||||
let ghost = {
|
let ghost = {
|
||||||
let animations = world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap().clone();
|
let animations = *world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap();
|
||||||
let atlas = world.non_send_resource::<SpriteAtlas>();
|
let atlas = world.non_send_resource::<SpriteAtlas>();
|
||||||
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
|
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
|
||||||
|
|
||||||
@@ -586,7 +557,7 @@ impl Game {
|
|||||||
TileSequence::new(&[left_eye]),
|
TileSequence::new(&[left_eye]),
|
||||||
TileSequence::new(&[right_eye]),
|
TileSequence::new(&[right_eye]),
|
||||||
);
|
);
|
||||||
let eyes = DirectionalAnimation::new(eyes_tiles.clone(), eyes_tiles, animation::GHOST_EATEN_SPEED);
|
let eyes = DirectionalAnimation::new(eyes_tiles, eyes_tiles, animation::GHOST_EATEN_SPEED);
|
||||||
|
|
||||||
let mut animations = HashMap::new();
|
let mut animations = HashMap::new();
|
||||||
|
|
||||||
@@ -615,7 +586,7 @@ impl Game {
|
|||||||
TileSequence::new(&left_tiles),
|
TileSequence::new(&left_tiles),
|
||||||
TileSequence::new(&right_tiles),
|
TileSequence::new(&right_tiles),
|
||||||
);
|
);
|
||||||
let normal = DirectionalAnimation::new(normal_moving.clone(), normal_moving, animation::GHOST_NORMAL_SPEED);
|
let normal = DirectionalAnimation::new(normal_moving, normal_moving, animation::GHOST_NORMAL_SPEED);
|
||||||
|
|
||||||
animations.insert(ghost_type, normal);
|
animations.insert(ghost_type, normal);
|
||||||
}
|
}
|
||||||
@@ -687,4 +658,68 @@ impl Game {
|
|||||||
|
|
||||||
state.exit
|
state.exit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /// Renders pathfinding debug lines from each ghost to Pac-Man.
|
||||||
|
// ///
|
||||||
|
// /// Each ghost's path is drawn in its respective color with a small offset
|
||||||
|
// /// to prevent overlapping lines.
|
||||||
|
// fn render_pathfinding_debug<T: sdl2::render::RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
|
||||||
|
// let pacman_node = self.state.pacman.current_node_id();
|
||||||
|
|
||||||
|
// for ghost in self.state.ghosts.iter() {
|
||||||
|
// if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) {
|
||||||
|
// if path.len() < 2 {
|
||||||
|
// continue; // Skip if path is too short
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Set the ghost's color
|
||||||
|
// canvas.set_draw_color(ghost.debug_color());
|
||||||
|
|
||||||
|
// // Calculate offset based on ghost index to prevent overlapping lines
|
||||||
|
// // let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0
|
||||||
|
|
||||||
|
// // Calculate a consistent offset direction for the entire path
|
||||||
|
// // let first_node = self.map.graph.get_node(path[0]).unwrap();
|
||||||
|
// // let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap();
|
||||||
|
|
||||||
|
// // Use the overall direction from start to end to determine the perpendicular offset
|
||||||
|
// let offset = match ghost.ghost_type {
|
||||||
|
// GhostType::Blinky => glam::Vec2::new(0.25, 0.5),
|
||||||
|
// GhostType::Pinky => glam::Vec2::new(-0.25, -0.25),
|
||||||
|
// GhostType::Inky => glam::Vec2::new(0.5, -0.5),
|
||||||
|
// GhostType::Clyde => glam::Vec2::new(-0.5, 0.25),
|
||||||
|
// } * 5.0;
|
||||||
|
|
||||||
|
// // Calculate offset positions for all nodes using the same perpendicular direction
|
||||||
|
// let mut offset_positions = Vec::new();
|
||||||
|
// for &node_id in &path {
|
||||||
|
// let node = self
|
||||||
|
// .state
|
||||||
|
// .map
|
||||||
|
// .graph
|
||||||
|
// .get_node(node_id)
|
||||||
|
// .ok_or(crate::error::EntityError::NodeNotFound(node_id))?;
|
||||||
|
// let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||||
|
// offset_positions.push(pos + offset);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Draw lines between the offset positions
|
||||||
|
// for window in offset_positions.windows(2) {
|
||||||
|
// if let (Some(from), Some(to)) = (window.first(), window.get(1)) {
|
||||||
|
// // Skip if the distance is too far (used for preventing lines between tunnel portals)
|
||||||
|
// if from.distance_squared(*to) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 {
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Draw the line
|
||||||
|
// canvas
|
||||||
|
// .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
|
||||||
|
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Ok(())
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/lib.rs
18
src/lib.rs
@@ -1,22 +1,14 @@
|
|||||||
//! Pac-Man game library crate.
|
//! Pac-Man game library crate.
|
||||||
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
|
||||||
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod audio;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod error;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod events;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod formatter;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod platform;
|
|
||||||
|
|
||||||
pub mod asset;
|
pub mod asset;
|
||||||
|
pub mod audio;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
|
pub mod error;
|
||||||
|
pub mod events;
|
||||||
|
pub mod formatter;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
pub mod platform;
|
||||||
pub mod systems;
|
pub mod systems;
|
||||||
pub mod texture;
|
pub mod texture;
|
||||||
|
|||||||
20
src/main.rs
20
src/main.rs
@@ -1,27 +1,20 @@
|
|||||||
// Note: This disables the console window on Windows. We manually re-attach to the parent terminal or process later on.
|
// Note: This disables the console window on Windows. We manually re-attach to the parent terminal or process later on.
|
||||||
#![windows_subsystem = "windows"]
|
#![windows_subsystem = "windows"]
|
||||||
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
|
||||||
|
|
||||||
use crate::{app::App, constants::LOOP_TIME};
|
use crate::{app::App, constants::LOOP_TIME};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
mod app;
|
mod app;
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
mod audio;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
mod error;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
mod events;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
mod formatter;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
mod platform;
|
|
||||||
|
|
||||||
mod asset;
|
mod asset;
|
||||||
|
mod audio;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod events;
|
||||||
|
mod formatter;
|
||||||
mod game;
|
mod game;
|
||||||
mod map;
|
mod map;
|
||||||
|
mod platform;
|
||||||
mod systems;
|
mod systems;
|
||||||
mod texture;
|
mod texture;
|
||||||
|
|
||||||
@@ -29,7 +22,6 @@ mod texture;
|
|||||||
///
|
///
|
||||||
/// This function initializes SDL, the window, the game state, and then enters
|
/// This function initializes SDL, the window, the game state, and then enters
|
||||||
/// the main game loop.
|
/// the main game loop.
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
// On Windows, this connects output streams to the console dynamically
|
// On Windows, this connects output streams to the console dynamically
|
||||||
// On Emscripten, this connects the subscriber to the browser console
|
// On Emscripten, this connects the subscriber to the browser console
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
|||||||
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
||||||
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
||||||
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
|
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
|
||||||
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman_death.wav"))),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ pub struct AudioState {
|
|||||||
pub enum AudioEvent {
|
pub enum AudioEvent {
|
||||||
/// Play the "eat" sound when Pac-Man consumes a pellet
|
/// Play the "eat" sound when Pac-Man consumes a pellet
|
||||||
PlayEat,
|
PlayEat,
|
||||||
/// Play the death sound
|
|
||||||
PlayDeath,
|
|
||||||
/// Stop all currently playing sounds
|
|
||||||
StopAll,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-send resource wrapper for SDL2 audio system
|
/// Non-send resource wrapper for SDL2 audio system
|
||||||
@@ -63,16 +59,6 @@ pub fn audio_system(
|
|||||||
// 4 eat sounds available
|
// 4 eat sounds available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioEvent::PlayDeath => {
|
|
||||||
if !audio.0.is_disabled() && !audio_state.muted {
|
|
||||||
audio.0.death();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioEvent::StopAll => {
|
|
||||||
if !audio.0.is_disabled() {
|
|
||||||
audio.0.stop_all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::component::Component;
|
||||||
component::Component,
|
use bevy_ecs::entity::Entity;
|
||||||
entity::Entity,
|
use bevy_ecs::event::{EventReader, EventWriter};
|
||||||
event::{EventReader, EventWriter},
|
use bevy_ecs::query::With;
|
||||||
query::With,
|
use bevy_ecs::system::{Query, Res, ResMut};
|
||||||
system::{Commands, Query, Res, ResMut},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::error::GameError;
|
use crate::error::GameError;
|
||||||
use crate::events::GameEvent;
|
use crate::events::GameEvent;
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::{
|
use crate::systems::movement::Position;
|
||||||
components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled,
|
use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource};
|
||||||
ScoreResource,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A component for defining the collision area of an entity.
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct Collider {
|
pub struct Collider {
|
||||||
pub size: f32,
|
pub size: f32,
|
||||||
@@ -67,7 +62,6 @@ pub fn check_collision(
|
|||||||
///
|
///
|
||||||
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
|
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
|
||||||
/// power pellet effects, ghost eating, and player death.
|
/// power pellet effects, ghost eating, and player death.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn collision_system(
|
pub fn collision_system(
|
||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
|
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
|
||||||
@@ -113,13 +107,10 @@ pub fn collision_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn ghost_collision_system(
|
pub fn ghost_collision_system(
|
||||||
mut commands: Commands,
|
|
||||||
mut collision_events: EventReader<GameEvent>,
|
mut collision_events: EventReader<GameEvent>,
|
||||||
mut score: ResMut<ScoreResource>,
|
mut score: ResMut<ScoreResource>,
|
||||||
mut game_state: ResMut<GameStage>,
|
pacman_query: Query<(), With<PlayerControlled>>,
|
||||||
pacman_query: Query<Entity, With<PlayerControlled>>,
|
|
||||||
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
|
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
|
||||||
mut ghost_state_query: Query<&mut GhostState>,
|
mut ghost_state_query: Query<&mut GhostState>,
|
||||||
mut events: EventWriter<AudioEvent>,
|
mut events: EventWriter<AudioEvent>,
|
||||||
@@ -127,7 +118,7 @@ pub fn ghost_collision_system(
|
|||||||
for event in collision_events.read() {
|
for event in collision_events.read() {
|
||||||
if let GameEvent::Collision(entity1, entity2) = event {
|
if let GameEvent::Collision(entity1, entity2) = event {
|
||||||
// Check if one is Pacman and the other is a ghost
|
// Check if one is Pacman and the other is a ghost
|
||||||
let (pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
|
let (_pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
|
||||||
(*entity1, *entity2)
|
(*entity1, *entity2)
|
||||||
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
|
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
|
||||||
(*entity2, *entity1)
|
(*entity2, *entity1)
|
||||||
@@ -149,12 +140,8 @@ pub fn ghost_collision_system(
|
|||||||
|
|
||||||
// Play eat sound
|
// Play eat sound
|
||||||
events.write(AudioEvent::PlayEat);
|
events.write(AudioEvent::PlayEat);
|
||||||
} else if matches!(*ghost_state, GhostState::Normal) {
|
} else {
|
||||||
// Pac-Man dies
|
// Pac-Man dies (this would need a death system)
|
||||||
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
|
|
||||||
commands.entity(pacman_entity).insert(Frozen);
|
|
||||||
commands.entity(ghost_entity).insert(Frozen);
|
|
||||||
events.write(AudioEvent::StopAll);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ pub struct Renderable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Directional animation component with shared timing across all directions
|
/// Directional animation component with shared timing across all directions
|
||||||
#[derive(Component, Clone)]
|
#[derive(Component, Clone, Copy)]
|
||||||
pub struct DirectionalAnimation {
|
pub struct DirectionalAnimation {
|
||||||
pub moving_tiles: DirectionalTiles,
|
pub moving_tiles: DirectionalTiles,
|
||||||
pub stopped_tiles: DirectionalTiles,
|
pub stopped_tiles: DirectionalTiles,
|
||||||
@@ -123,18 +123,13 @@ impl DirectionalAnimation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag component to mark animations that should loop when they reach the end
|
|
||||||
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Looping;
|
|
||||||
|
|
||||||
/// Linear animation component for non-directional animations (frightened ghosts)
|
/// Linear animation component for non-directional animations (frightened ghosts)
|
||||||
#[derive(Component, Resource, Clone)]
|
#[derive(Component, Clone, Copy)]
|
||||||
pub struct LinearAnimation {
|
pub struct LinearAnimation {
|
||||||
pub tiles: TileSequence,
|
pub tiles: TileSequence,
|
||||||
pub current_frame: usize,
|
pub current_frame: usize,
|
||||||
pub time_bank: u16,
|
pub time_bank: u16,
|
||||||
pub frame_duration: u16,
|
pub frame_duration: u16,
|
||||||
pub finished: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LinearAnimation {
|
impl LinearAnimation {
|
||||||
@@ -145,7 +140,6 @@ impl LinearAnimation {
|
|||||||
current_frame: 0,
|
current_frame: 0,
|
||||||
time_bank: 0,
|
time_bank: 0,
|
||||||
frame_duration,
|
frame_duration,
|
||||||
finished: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,11 +218,6 @@ pub struct Frozen;
|
|||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct Eaten;
|
pub struct Eaten;
|
||||||
|
|
||||||
/// Tag component for Pac-Man during his death animation.
|
|
||||||
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct Dying;
|
|
||||||
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub enum GhostState {
|
pub enum GhostState {
|
||||||
/// Normal ghost behavior - chasing Pac-Man
|
/// Normal ghost behavior - chasing Pac-Man
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Debug rendering system
|
//! Debug rendering system
|
||||||
#[cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
use crate::constants::{self, BOARD_PIXEL_OFFSET};
|
use crate::constants::{self, BOARD_PIXEL_OFFSET};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings};
|
use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings};
|
||||||
@@ -12,7 +13,6 @@ use sdl2::rect::{Point, Rect};
|
|||||||
use sdl2::render::{Canvas, Texture};
|
use sdl2::render::{Canvas, Texture};
|
||||||
use sdl2::video::Window;
|
use sdl2::video::Window;
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
use std::cmp::Ordering;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
@@ -149,7 +149,6 @@ fn transform_position_with_offset(pos: Vec2, scale: f32) -> IVec2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Renders timing information in the top-left corner of the screen using the debug text atlas
|
/// Renders timing information in the top-left corner of the screen using the debug text atlas
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
fn render_timing_display(
|
fn render_timing_display(
|
||||||
canvas: &mut Canvas<Window>,
|
canvas: &mut Canvas<Window>,
|
||||||
timings: &SystemTimings,
|
timings: &SystemTimings,
|
||||||
@@ -204,7 +203,6 @@ fn render_timing_display(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub fn debug_render_system(
|
pub fn debug_render_system(
|
||||||
canvas: &mut Canvas<Window>,
|
canvas: &mut Canvas<Window>,
|
||||||
ttf_atlas: &mut TtfAtlasResource,
|
ttf_atlas: &mut TtfAtlasResource,
|
||||||
@@ -268,7 +266,7 @@ pub fn debug_render_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas.set_draw_color(Color {
|
canvas.set_draw_color(Color {
|
||||||
a: f32_to_u8(0.65),
|
a: f32_to_u8(0.6),
|
||||||
..Color::RED
|
..Color::RED
|
||||||
});
|
});
|
||||||
canvas.set_blend_mode(sdl2::render::BlendMode::Blend);
|
canvas.set_blend_mode(sdl2::render::BlendMode::Blend);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use crate::platform;
|
use crate::platform;
|
||||||
use crate::systems::components::{
|
use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation};
|
||||||
DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping,
|
|
||||||
};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
map::{
|
map::{
|
||||||
builder::Map,
|
builder::Map,
|
||||||
@@ -196,26 +194,22 @@ pub fn ghost_state_system(
|
|||||||
if last_animation_state.0 != current_animation_state {
|
if last_animation_state.0 != current_animation_state {
|
||||||
match current_animation_state {
|
match current_animation_state {
|
||||||
GhostAnimation::Frightened { flash } => {
|
GhostAnimation::Frightened { flash } => {
|
||||||
// Remove DirectionalAnimation, add LinearAnimation with Looping component
|
// Remove DirectionalAnimation, add LinearAnimation
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<DirectionalAnimation>()
|
.remove::<DirectionalAnimation>()
|
||||||
.insert(animations.frightened(flash).clone())
|
.insert(*animations.frightened(flash));
|
||||||
.insert(Looping);
|
|
||||||
}
|
}
|
||||||
GhostAnimation::Normal => {
|
GhostAnimation::Normal => {
|
||||||
// Remove LinearAnimation and Looping, add DirectionalAnimation
|
// Remove LinearAnimation, add DirectionalAnimation
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<(LinearAnimation, Looping)>()
|
.remove::<LinearAnimation>()
|
||||||
.insert(animations.get_normal(ghost_type).unwrap().clone());
|
.insert(*animations.get_normal(ghost_type).unwrap());
|
||||||
}
|
}
|
||||||
GhostAnimation::Eyes => {
|
GhostAnimation::Eyes => {
|
||||||
// Remove LinearAnimation and Looping, add DirectionalAnimation (eyes animation)
|
// Remove LinearAnimation, add DirectionalAnimation (eyes animation)
|
||||||
commands
|
commands.entity(entity).remove::<LinearAnimation>().insert(*animations.eyes());
|
||||||
.entity(entity)
|
|
||||||
.remove::<(LinearAnimation, Looping)>()
|
|
||||||
.insert(animations.eyes().clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
last_animation_state.0 = current_animation_state;
|
last_animation_state.0 = current_animation_state;
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
//! This module contains all the systems in the game.
|
//! The Entity-Component-System (ECS) module.
|
||||||
|
//!
|
||||||
|
//! This module contains all the ECS-related logic, including components, systems,
|
||||||
|
//! and resources.
|
||||||
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod debug;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod profiling;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod render;
|
|
||||||
|
|
||||||
pub mod blinking;
|
pub mod blinking;
|
||||||
pub mod collision;
|
pub mod collision;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
|
pub mod debug;
|
||||||
pub mod ghost;
|
pub mod ghost;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod state;
|
pub mod profiling;
|
||||||
|
pub mod render;
|
||||||
// Re-export all the modules. Do not fine-tune the exports.
|
pub mod stage;
|
||||||
|
|
||||||
pub use self::audio::*;
|
pub use self::audio::*;
|
||||||
pub use self::blinking::*;
|
pub use self::blinking::*;
|
||||||
@@ -33,4 +29,4 @@ pub use self::movement::*;
|
|||||||
pub use self::player::*;
|
pub use self::player::*;
|
||||||
pub use self::profiling::*;
|
pub use self::profiling::*;
|
||||||
pub use self::render::*;
|
pub use self::render::*;
|
||||||
pub use self::state::*;
|
pub use self::stage::*;
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
|
use crate::constants::CANVAS_SIZE;
|
||||||
|
use crate::error::{GameError, TextureError};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::input::TouchState;
|
use crate::systems::input::TouchState;
|
||||||
use crate::systems::{
|
use crate::systems::{
|
||||||
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
|
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
|
||||||
DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLives, Position, Renderable, ScoreResource,
|
DirectionalAnimation, LinearAnimation, Position, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings,
|
||||||
StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity,
|
TtfAtlasResource, Velocity,
|
||||||
};
|
};
|
||||||
use crate::texture::sprite::SpriteAtlas;
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
use crate::texture::text::TextTexture;
|
use crate::texture::text::TextTexture;
|
||||||
use crate::{
|
|
||||||
constants::CANVAS_SIZE,
|
|
||||||
error::{GameError, TextureError},
|
|
||||||
};
|
|
||||||
use bevy_ecs::component::Component;
|
use bevy_ecs::component::Component;
|
||||||
use bevy_ecs::entity::Entity;
|
use bevy_ecs::entity::Entity;
|
||||||
use bevy_ecs::event::EventWriter;
|
use bevy_ecs::event::EventWriter;
|
||||||
use bevy_ecs::query::{Changed, Has, Or, With, Without};
|
use bevy_ecs::query::{Changed, Or, Without};
|
||||||
use bevy_ecs::removal_detection::RemovedComponents;
|
use bevy_ecs::removal_detection::RemovedComponents;
|
||||||
use bevy_ecs::resource::Resource;
|
use bevy_ecs::resource::Resource;
|
||||||
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
||||||
@@ -55,7 +53,7 @@ pub fn dirty_render_system(
|
|||||||
/// All directions share the same frame timing to ensure perfect synchronization.
|
/// All directions share the same frame timing to ensure perfect synchronization.
|
||||||
pub fn directional_render_system(
|
pub fn directional_render_system(
|
||||||
dt: Res<DeltaTime>,
|
dt: Res<DeltaTime>,
|
||||||
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without<Frozen>>,
|
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable)>,
|
||||||
) {
|
) {
|
||||||
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
||||||
|
|
||||||
@@ -88,35 +86,26 @@ pub fn directional_render_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System that updates `Renderable` sprites for entities with `LinearAnimation`.
|
/// Updates linear animated entities (used for non-directional animations like frightened ghosts).
|
||||||
#[allow(clippy::type_complexity)]
|
///
|
||||||
pub fn linear_render_system(
|
/// This system handles entities that use LinearAnimation component for simple frame cycling.
|
||||||
dt: Res<DeltaTime>,
|
pub fn linear_render_system(dt: Res<DeltaTime>, mut query: Query<(&mut LinearAnimation, &mut Renderable)>) {
|
||||||
mut query: Query<(&mut LinearAnimation, &mut Renderable, Has<Looping>), Or<(Without<Frozen>, With<Dying>)>>,
|
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
||||||
) {
|
|
||||||
for (mut anim, mut renderable, looping) in query.iter_mut() {
|
for (mut anim, mut renderable) in query.iter_mut() {
|
||||||
if anim.finished {
|
// Tick animation
|
||||||
continue;
|
anim.time_bank += ticks;
|
||||||
|
while anim.time_bank >= anim.frame_duration {
|
||||||
|
anim.time_bank -= anim.frame_duration;
|
||||||
|
anim.current_frame += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
anim.time_bank += dt.ticks as u16;
|
if !anim.tiles.is_empty() {
|
||||||
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
|
let new_tile = anim.tiles.get_tile(anim.current_frame);
|
||||||
|
if renderable.sprite != new_tile {
|
||||||
if frames_to_advance == 0 {
|
renderable.sprite = new_tile;
|
||||||
continue;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_frames = anim.tiles.len();
|
|
||||||
|
|
||||||
if !looping && anim.current_frame + frames_to_advance >= total_frames {
|
|
||||||
anim.finished = true;
|
|
||||||
anim.current_frame = total_frames - 1;
|
|
||||||
} else {
|
|
||||||
anim.current_frame += frames_to_advance;
|
|
||||||
}
|
|
||||||
|
|
||||||
anim.time_bank %= anim.frame_duration;
|
|
||||||
renderable.sprite = anim.tiles.get_tile(anim.current_frame);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,16 +193,15 @@ pub fn hud_render_system(
|
|||||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
mut atlas: NonSendMut<SpriteAtlas>,
|
mut atlas: NonSendMut<SpriteAtlas>,
|
||||||
player_lives: Res<PlayerLives>,
|
|
||||||
score: Res<ScoreResource>,
|
score: Res<ScoreResource>,
|
||||||
stage: Res<GameStage>,
|
startup: Res<StartupSequence>,
|
||||||
mut errors: EventWriter<GameError>,
|
mut errors: EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
||||||
let mut text_renderer = TextTexture::new(1.0);
|
let mut text_renderer = TextTexture::new(1.0);
|
||||||
|
|
||||||
// Render lives and high score text in white
|
// Render lives and high score text in white
|
||||||
let lives = player_lives.0;
|
let lives = 3; // TODO: Get from actual lives resource
|
||||||
let lives_text = format!("{lives}UP HIGH SCORE ");
|
let lives_text = format!("{lives}UP HIGH SCORE ");
|
||||||
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
|
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
|
||||||
|
|
||||||
@@ -238,21 +226,10 @@ pub fn hud_render_system(
|
|||||||
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render GAME OVER text
|
|
||||||
if matches!(*stage, GameStage::GameOver) {
|
|
||||||
let game_over_text = "GAME OVER";
|
|
||||||
let game_over_width = text_renderer.text_width(game_over_text);
|
|
||||||
let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160);
|
|
||||||
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render text based on StartupSequence stage
|
// Render text based on StartupSequence stage
|
||||||
if matches!(
|
if matches!(
|
||||||
*stage,
|
*startup,
|
||||||
GameStage::Starting(StartupSequence::TextOnly { .. })
|
StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. }
|
||||||
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
|
|
||||||
) {
|
) {
|
||||||
let ready_text = "READY!";
|
let ready_text = "READY!";
|
||||||
let ready_width = text_renderer.text_width(ready_text);
|
let ready_width = text_renderer.text_width(ready_text);
|
||||||
@@ -261,7 +238,7 @@ pub fn hud_render_system(
|
|||||||
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
|
if matches!(*startup, StartupSequence::TextOnly { .. }) {
|
||||||
let player_one_text = "PLAYER ONE";
|
let player_one_text = "PLAYER ONE";
|
||||||
let player_one_width = text_renderer.text_width(player_one_text);
|
let player_one_width = text_renderer.text_width(player_one_text);
|
||||||
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
|
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
|
||||||
|
|||||||
101
src/systems/stage.rs
Normal file
101
src/systems/stage.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use bevy_ecs::{
|
||||||
|
entity::Entity,
|
||||||
|
query::With,
|
||||||
|
resource::Resource,
|
||||||
|
system::{Commands, Query, ResMut},
|
||||||
|
};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::systems::{Blinking, Frozen, GhostCollider, Hidden, PlayerControlled};
|
||||||
|
|
||||||
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
|
pub enum StartupSequence {
|
||||||
|
/// Stage 1: Text-only stage
|
||||||
|
/// - Player & ghosts are hidden
|
||||||
|
/// - READY! and PLAYER ONE text are shown
|
||||||
|
/// - Energizers do not blink
|
||||||
|
TextOnly {
|
||||||
|
/// Remaining ticks in this stage
|
||||||
|
remaining_ticks: u32,
|
||||||
|
},
|
||||||
|
/// Stage 2: Characters visible stage
|
||||||
|
/// - PLAYER ONE text is hidden, READY! text remains
|
||||||
|
/// - Ghosts and Pac-Man are now shown
|
||||||
|
CharactersVisible {
|
||||||
|
/// Remaining ticks in this stage
|
||||||
|
remaining_ticks: u32,
|
||||||
|
},
|
||||||
|
/// Stage 3: Game begins
|
||||||
|
/// - Final state, game is fully active
|
||||||
|
GameActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StartupSequence {
|
||||||
|
/// Creates a new StartupSequence with the specified duration in ticks
|
||||||
|
pub fn new(text_only_ticks: u32, _characters_visible_ticks: u32) -> Self {
|
||||||
|
Self::TextOnly {
|
||||||
|
remaining_ticks: text_only_ticks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticks the timer by one frame, returning transition information if state changes
|
||||||
|
pub fn tick(&mut self) -> Option<(StartupSequence, StartupSequence)> {
|
||||||
|
match self {
|
||||||
|
StartupSequence::TextOnly { remaining_ticks } => {
|
||||||
|
if *remaining_ticks > 0 {
|
||||||
|
*remaining_ticks -= 1;
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let from = *self;
|
||||||
|
*self = StartupSequence::CharactersVisible {
|
||||||
|
remaining_ticks: 60, // 1 second at 60 FPS
|
||||||
|
};
|
||||||
|
Some((from, *self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StartupSequence::CharactersVisible { remaining_ticks } => {
|
||||||
|
if *remaining_ticks > 0 {
|
||||||
|
*remaining_ticks -= 1;
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let from = *self;
|
||||||
|
*self = StartupSequence::GameActive;
|
||||||
|
Some((from, *self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StartupSequence::GameActive => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles startup sequence transitions and component management
|
||||||
|
pub fn startup_stage_system(
|
||||||
|
mut startup: ResMut<StartupSequence>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut blinking_query: Query<Entity, With<Blinking>>,
|
||||||
|
mut player_query: Query<Entity, With<PlayerControlled>>,
|
||||||
|
mut ghost_query: Query<Entity, With<GhostCollider>>,
|
||||||
|
) {
|
||||||
|
if let Some((from, to)) = startup.tick() {
|
||||||
|
debug!("StartupSequence transition from {from:?} to {to:?}");
|
||||||
|
match (from, to) {
|
||||||
|
(StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
|
||||||
|
// Unhide the player & ghosts
|
||||||
|
for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) {
|
||||||
|
commands.entity(entity).remove::<Hidden>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
|
||||||
|
// Unfreeze the player & ghosts & pellet blinking
|
||||||
|
for entity in player_query
|
||||||
|
.iter_mut()
|
||||||
|
.chain(ghost_query.iter_mut())
|
||||||
|
.chain(blinking_query.iter_mut())
|
||||||
|
{
|
||||||
|
commands.entity(entity).remove::<Frozen>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
use std::mem::discriminant;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
map::builder::Map,
|
|
||||||
systems::{
|
|
||||||
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
|
|
||||||
LinearAnimation, Looping, PlayerControlled, Position,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use bevy_ecs::{
|
|
||||||
entity::Entity,
|
|
||||||
event::EventWriter,
|
|
||||||
query::{With, Without},
|
|
||||||
resource::Resource,
|
|
||||||
system::{Commands, Query, Res, ResMut},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Resource, Clone)]
|
|
||||||
pub struct PlayerAnimation(pub DirectionalAnimation);
|
|
||||||
|
|
||||||
#[derive(Resource, Clone)]
|
|
||||||
pub struct PlayerDeathAnimation(pub LinearAnimation);
|
|
||||||
|
|
||||||
/// A resource to track the overall stage of the game from a high-level perspective.
|
|
||||||
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
|
|
||||||
pub enum GameStage {
|
|
||||||
Starting(StartupSequence),
|
|
||||||
/// The main gameplay loop is active.
|
|
||||||
Playing,
|
|
||||||
/// The player has died and the death sequence is in progress.
|
|
||||||
PlayerDying(DyingSequence),
|
|
||||||
/// The level is restarting after a death.
|
|
||||||
LevelRestarting,
|
|
||||||
/// The game has ended.
|
|
||||||
GameOver,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A resource that manages the multi-stage startup sequence of the game.
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
||||||
pub enum StartupSequence {
|
|
||||||
/// Stage 1: Text-only stage
|
|
||||||
/// - Player & ghosts are hidden
|
|
||||||
/// - READY! and PLAYER ONE text are shown
|
|
||||||
/// - Energizers do not blink
|
|
||||||
TextOnly {
|
|
||||||
/// Remaining ticks in this stage
|
|
||||||
remaining_ticks: u32,
|
|
||||||
},
|
|
||||||
/// Stage 2: Characters visible stage
|
|
||||||
/// - PLAYER ONE text is hidden, READY! text remains
|
|
||||||
/// - Ghosts and Pac-Man are now shown
|
|
||||||
CharactersVisible {
|
|
||||||
/// Remaining ticks in this stage
|
|
||||||
remaining_ticks: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GameStage {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Playing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The state machine for the multi-stage death sequence.
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
||||||
pub enum DyingSequence {
|
|
||||||
/// Initial stage: entities are frozen, waiting for a delay.
|
|
||||||
Frozen { remaining_ticks: u32 },
|
|
||||||
/// Second stage: Pac-Man's death animation is playing.
|
|
||||||
Animating { remaining_ticks: u32 },
|
|
||||||
/// Third stage: Pac-Man is now gone, waiting a moment before the level restarts.
|
|
||||||
Hidden { remaining_ticks: u32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A resource to store the number of player lives.
|
|
||||||
#[derive(Resource, Debug)]
|
|
||||||
pub struct PlayerLives(pub u8);
|
|
||||||
|
|
||||||
impl Default for PlayerLives {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self(3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles startup sequence transitions and component management
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub fn stage_system(
|
|
||||||
mut game_state: ResMut<GameStage>,
|
|
||||||
player_death_animation: Res<PlayerDeathAnimation>,
|
|
||||||
player_animation: Res<PlayerAnimation>,
|
|
||||||
mut player_lives: ResMut<PlayerLives>,
|
|
||||||
map: Res<Map>,
|
|
||||||
mut commands: Commands,
|
|
||||||
mut audio_events: EventWriter<AudioEvent>,
|
|
||||||
mut blinking_query: Query<Entity, With<Blinking>>,
|
|
||||||
mut player_query: Query<(Entity, &mut Position), With<PlayerControlled>>,
|
|
||||||
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
|
|
||||||
) {
|
|
||||||
let old_state = *game_state;
|
|
||||||
let new_state: GameStage = match &mut *game_state {
|
|
||||||
GameStage::Starting(startup) => match startup {
|
|
||||||
StartupSequence::TextOnly { remaining_ticks } => {
|
|
||||||
if *remaining_ticks > 0 {
|
|
||||||
GameStage::Starting(StartupSequence::TextOnly {
|
|
||||||
remaining_ticks: *remaining_ticks - 1,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StartupSequence::CharactersVisible { remaining_ticks } => {
|
|
||||||
if *remaining_ticks > 0 {
|
|
||||||
GameStage::Starting(StartupSequence::CharactersVisible {
|
|
||||||
remaining_ticks: *remaining_ticks - 1,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
GameStage::Playing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
GameStage::Playing => GameStage::Playing,
|
|
||||||
GameStage::PlayerDying(dying) => match dying {
|
|
||||||
DyingSequence::Frozen { remaining_ticks } => {
|
|
||||||
if *remaining_ticks > 0 {
|
|
||||||
GameStage::PlayerDying(DyingSequence::Frozen {
|
|
||||||
remaining_ticks: *remaining_ticks - 1,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let death_animation = &player_death_animation.0;
|
|
||||||
let remaining_ticks = (death_animation.tiles.len() * death_animation.frame_duration as usize) as u32;
|
|
||||||
GameStage::PlayerDying(DyingSequence::Animating { remaining_ticks })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DyingSequence::Animating { remaining_ticks } => {
|
|
||||||
if *remaining_ticks > 0 {
|
|
||||||
GameStage::PlayerDying(DyingSequence::Animating {
|
|
||||||
remaining_ticks: *remaining_ticks - 1,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DyingSequence::Hidden { remaining_ticks } => {
|
|
||||||
if *remaining_ticks > 0 {
|
|
||||||
GameStage::PlayerDying(DyingSequence::Hidden {
|
|
||||||
remaining_ticks: *remaining_ticks - 1,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
player_lives.0 = player_lives.0.saturating_sub(1);
|
|
||||||
|
|
||||||
if player_lives.0 > 0 {
|
|
||||||
GameStage::LevelRestarting
|
|
||||||
} else {
|
|
||||||
GameStage::GameOver
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
GameStage::LevelRestarting => GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }),
|
|
||||||
GameStage::GameOver => GameStage::GameOver,
|
|
||||||
};
|
|
||||||
|
|
||||||
if old_state == new_state {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match (old_state, new_state) {
|
|
||||||
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
|
|
||||||
// Freeze the player & ghosts
|
|
||||||
for entity in player_query
|
|
||||||
.iter_mut()
|
|
||||||
.map(|(e, _)| e)
|
|
||||||
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
|
|
||||||
{
|
|
||||||
commands.entity(entity).insert(Frozen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
|
|
||||||
// Hide the ghosts
|
|
||||||
for (entity, _, _) in ghost_query.iter_mut() {
|
|
||||||
commands.entity(entity).insert(Hidden);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start Pac-Man's death animation
|
|
||||||
if let Ok((player_entity, _)) = player_query.single_mut() {
|
|
||||||
commands
|
|
||||||
.entity(player_entity)
|
|
||||||
.insert((Dying, player_death_animation.0.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Play the death sound
|
|
||||||
audio_events.write(AudioEvent::PlayDeath);
|
|
||||||
}
|
|
||||||
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
|
|
||||||
// Hide the player
|
|
||||||
if let Ok((player_entity, _)) = player_query.single_mut() {
|
|
||||||
commands.entity(player_entity).insert(Hidden);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, GameStage::LevelRestarting) => {
|
|
||||||
if let Ok((player_entity, mut pos)) = player_query.single_mut() {
|
|
||||||
*pos = Position::Stopped {
|
|
||||||
node: map.start_positions.pacman,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Freeze the blinking, force them to be visible (if they were hidden by blinking)
|
|
||||||
for entity in blinking_query.iter_mut() {
|
|
||||||
commands.entity(entity).insert(Frozen).remove::<Hidden>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the player animation
|
|
||||||
commands
|
|
||||||
.entity(player_entity)
|
|
||||||
.remove::<(Frozen, Dying, Hidden, LinearAnimation, Looping)>()
|
|
||||||
.insert(player_animation.0.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset ghost positions and state
|
|
||||||
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() {
|
|
||||||
*ghost_pos = Position::Stopped {
|
|
||||||
node: match ghost {
|
|
||||||
Ghost::Blinky => map.start_positions.blinky,
|
|
||||||
Ghost::Pinky => map.start_positions.pinky,
|
|
||||||
Ghost::Inky => map.start_positions.inky,
|
|
||||||
Ghost::Clyde => map.start_positions.clyde,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
commands
|
|
||||||
.entity(ghost_entity)
|
|
||||||
.remove::<(Frozen, Hidden, Eaten)>()
|
|
||||||
.insert(GhostState::Normal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(
|
|
||||||
GameStage::Starting(StartupSequence::TextOnly { .. }),
|
|
||||||
GameStage::Starting(StartupSequence::CharactersVisible { .. }),
|
|
||||||
) => {
|
|
||||||
// Unhide the player & ghosts
|
|
||||||
for entity in player_query
|
|
||||||
.iter_mut()
|
|
||||||
.map(|(e, _)| e)
|
|
||||||
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
|
|
||||||
{
|
|
||||||
commands.entity(entity).remove::<Hidden>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
|
|
||||||
// Unfreeze the player & ghosts & blinking
|
|
||||||
for entity in player_query
|
|
||||||
.iter_mut()
|
|
||||||
.map(|(e, _)| e)
|
|
||||||
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
|
|
||||||
.chain(blinking_query.iter_mut())
|
|
||||||
{
|
|
||||||
commands.entity(entity).remove::<Frozen>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(GameStage::PlayerDying(..), GameStage::GameOver) => {
|
|
||||||
// Freeze blinking
|
|
||||||
for entity in blinking_query.iter_mut() {
|
|
||||||
commands.entity(entity).insert(Frozen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let different = discriminant(&old_state) != discriminant(&new_state);
|
|
||||||
if different {
|
|
||||||
tracing::warn!(
|
|
||||||
new_state = ?new_state,
|
|
||||||
old_state = ?old_state,
|
|
||||||
"Unhandled game stage transition");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*game_state = new_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if let GameState::LevelRestarting = &*game_state {
|
|
||||||
// // When restarting, jump straight to the CharactersVisible stage
|
|
||||||
// // and unhide the entities.
|
|
||||||
// *startup = StartupSequence::new(0, 60 * 2); // 2 seconds for READY! text
|
|
||||||
// if let StartupSequence::TextOnly { .. } = *startup {
|
|
||||||
// // This will immediately transition to CharactersVisible on the next line
|
|
||||||
// } else {
|
|
||||||
// // Should be unreachable as we just set it
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Freeze Pac-Man and ghosts
|
|
||||||
// for entity in player_query.iter().chain(ghost_query.iter()) {
|
|
||||||
// commands.entity(entity).insert(Frozen);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// *game_state = GameState::Playing;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if let Some((old_state, new_state)) = startup.tick() {
|
|
||||||
// debug!("StartupSequence transition from {old_state:?} to {new_state:?}");
|
|
||||||
// match (old_state, new_state) {
|
|
||||||
// (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
|
|
||||||
// // Unhide the player & ghosts
|
|
||||||
// for entity in player_query.iter().chain(ghost_query.iter()) {
|
|
||||||
// commands.entity(entity).remove::<Hidden>();
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// (StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
|
|
||||||
// // Unfreeze Pac-Man, ghosts and energizers
|
|
||||||
// for entity in player_query.iter().chain(ghost_query.iter()).chain(blinking_query.iter()) {
|
|
||||||
// commands.entity(entity).remove::<Frozen>();
|
|
||||||
// }
|
|
||||||
// *game_state = GameState::Playing;
|
|
||||||
// }
|
|
||||||
// _ => {}
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@@ -1,45 +1,53 @@
|
|||||||
use glam::U16Vec2;
|
use crate::map::direction::Direction;
|
||||||
|
use crate::texture::sprite::AtlasTile;
|
||||||
|
|
||||||
use crate::{map::direction::Direction, texture::sprite::AtlasTile};
|
/// Fixed-size tile sequence that avoids heap allocation
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
/// A sequence of tiles for animation, backed by a vector.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TileSequence {
|
pub struct TileSequence {
|
||||||
tiles: Vec<AtlasTile>,
|
tiles: [AtlasTile; 4], // Fixed array, max 4 frames
|
||||||
|
count: usize, // Actual number of frames used
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TileSequence {
|
impl TileSequence {
|
||||||
/// Creates a new tile sequence from a slice of tiles.
|
/// Creates a new tile sequence from a slice of tiles
|
||||||
pub fn new(tiles: &[AtlasTile]) -> Self {
|
pub fn new(tiles: &[AtlasTile]) -> Self {
|
||||||
Self { tiles: tiles.to_vec() }
|
let mut tile_array = [AtlasTile {
|
||||||
|
pos: glam::U16Vec2::ZERO,
|
||||||
|
size: glam::U16Vec2::ZERO,
|
||||||
|
color: None,
|
||||||
|
}; 4];
|
||||||
|
|
||||||
|
let count = tiles.len().min(4);
|
||||||
|
tile_array[..count].copy_from_slice(&tiles[..count]);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tiles: tile_array,
|
||||||
|
count,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the tile at the given frame index, wrapping if necessary
|
/// Returns the tile at the given frame index, wrapping if necessary
|
||||||
pub fn get_tile(&self, frame: usize) -> AtlasTile {
|
pub fn get_tile(&self, frame: usize) -> AtlasTile {
|
||||||
if self.tiles.is_empty() {
|
if self.count == 0 {
|
||||||
// Return a default or handle the error appropriately
|
// Return a default empty tile if no tiles
|
||||||
// For now, let's return a default tile, assuming it's a sensible default
|
AtlasTile {
|
||||||
return AtlasTile {
|
pos: glam::U16Vec2::ZERO,
|
||||||
pos: U16Vec2::ZERO,
|
size: glam::U16Vec2::ZERO,
|
||||||
size: U16Vec2::ZERO,
|
|
||||||
color: None,
|
color: None,
|
||||||
};
|
}
|
||||||
|
} else {
|
||||||
|
self.tiles[frame % self.count]
|
||||||
}
|
}
|
||||||
self.tiles[frame % self.tiles.len()]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
/// Returns true if this sequence has no tiles
|
||||||
self.tiles.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if the sequence contains any tiles.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.tiles.is_empty()
|
self.count == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A collection of tile sequences for each cardinal direction.
|
/// Type-safe directional tile storage with named fields
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct DirectionalTiles {
|
pub struct DirectionalTiles {
|
||||||
pub up: TileSequence,
|
pub up: TileSequence,
|
||||||
pub down: TileSequence,
|
pub down: TileSequence,
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ pub enum PacmanSprite {
|
|||||||
Moving(Direction, u8),
|
Moving(Direction, u8),
|
||||||
/// The full, closed-mouth Pac-Man sprite.
|
/// The full, closed-mouth Pac-Man sprite.
|
||||||
Full,
|
Full,
|
||||||
/// A single frame of the dying animation.
|
|
||||||
Dying(u8),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the color of a frightened ghost.
|
/// Represents the color of a frightened ghost.
|
||||||
@@ -62,50 +60,45 @@ impl GameSprite {
|
|||||||
/// This path corresponds to the filename in the texture atlas JSON file.
|
/// This path corresponds to the filename in the texture atlas JSON file.
|
||||||
pub fn to_path(self) -> String {
|
pub fn to_path(self) -> String {
|
||||||
match self {
|
match self {
|
||||||
GameSprite::Pacman(PacmanSprite::Moving(dir, frame)) => format!(
|
GameSprite::Pacman(sprite) => match sprite {
|
||||||
"pacman/{}_{}.png",
|
PacmanSprite::Moving(dir, frame) => {
|
||||||
dir.as_ref(),
|
let frame_char = match frame {
|
||||||
match frame {
|
0 => 'a',
|
||||||
0 => "a",
|
1 => 'b',
|
||||||
1 => "b",
|
_ => panic!("Invalid animation frame"),
|
||||||
_ => panic!("Invalid animation frame"),
|
};
|
||||||
|
format!("pacman/{}_{}.png", dir.as_ref().to_lowercase(), frame_char)
|
||||||
}
|
}
|
||||||
),
|
PacmanSprite::Full => "pacman/full.png".to_string(),
|
||||||
GameSprite::Pacman(PacmanSprite::Full) => "pacman/full.png".to_string(),
|
},
|
||||||
GameSprite::Pacman(PacmanSprite::Dying(frame)) => format!("pacman/death/{}.png", frame),
|
GameSprite::Ghost(sprite) => match sprite {
|
||||||
|
GhostSprite::Normal(ghost, dir, frame) => {
|
||||||
// Ghost sprites
|
let frame_char = match frame {
|
||||||
GameSprite::Ghost(GhostSprite::Normal(ghost_type, dir, frame)) => {
|
0 => 'a',
|
||||||
let frame_char = match frame {
|
1 => 'b',
|
||||||
0 => 'a',
|
_ => panic!("Invalid animation frame"),
|
||||||
1 => 'b',
|
};
|
||||||
_ => panic!("Invalid animation frame"),
|
format!("ghost/{}/{}_{}.png", ghost.as_str(), dir.as_ref().to_lowercase(), frame_char)
|
||||||
};
|
}
|
||||||
format!(
|
GhostSprite::Frightened(color, frame) => {
|
||||||
"ghost/{}/{}_{}.png",
|
let frame_char = match frame {
|
||||||
ghost_type.as_str(),
|
0 => 'a',
|
||||||
dir.as_ref().to_lowercase(),
|
1 => 'b',
|
||||||
frame_char
|
_ => panic!("Invalid animation frame"),
|
||||||
)
|
};
|
||||||
}
|
let color_str = match color {
|
||||||
GameSprite::Ghost(GhostSprite::Frightened(color, frame)) => {
|
FrightenedColor::Blue => "blue",
|
||||||
let frame_char = match frame {
|
FrightenedColor::White => "white",
|
||||||
0 => 'a',
|
};
|
||||||
1 => 'b',
|
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
|
||||||
_ => panic!("Invalid animation frame"),
|
}
|
||||||
};
|
GhostSprite::Eyes(dir) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
|
||||||
let color_str = match color {
|
},
|
||||||
FrightenedColor::Blue => "blue",
|
GameSprite::Maze(sprite) => match sprite {
|
||||||
FrightenedColor::White => "white",
|
MazeSprite::Tile(index) => format!("maze/tiles/{}.png", index),
|
||||||
};
|
MazeSprite::Pellet => "maze/pellet.png".to_string(),
|
||||||
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
|
MazeSprite::Energizer => "maze/energizer.png".to_string(),
|
||||||
}
|
},
|
||||||
GameSprite::Ghost(GhostSprite::Eyes(dir)) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
|
|
||||||
|
|
||||||
// Maze sprites
|
|
||||||
GameSprite::Maze(MazeSprite::Tile(index)) => format!("maze/tiles/{}.png", index),
|
|
||||||
GameSprite::Maze(MazeSprite::Pellet) => "maze/pellet.png".to_string(),
|
|
||||||
GameSprite::Maze(MazeSprite::Energizer) => "maze/energizer.png".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
//! Tests for the sprite path generation.
|
|
||||||
use pacman::{
|
|
||||||
game::ATLAS_FRAMES,
|
|
||||||
map::direction::Direction,
|
|
||||||
systems::components::Ghost,
|
|
||||||
texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_all_sprite_paths_exist() {
|
|
||||||
let mut sprites_to_test = Vec::new();
|
|
||||||
|
|
||||||
// Pac-Man sprites
|
|
||||||
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
|
||||||
for frame in 0..2 {
|
|
||||||
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Moving(dir, frame)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Full));
|
|
||||||
for frame in 0..=10 {
|
|
||||||
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Dying(frame)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ghost sprites
|
|
||||||
for &ghost in &[Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
|
|
||||||
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
|
||||||
for frame in 0..2 {
|
|
||||||
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Normal(ghost, dir, frame)));
|
|
||||||
}
|
|
||||||
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Eyes(dir)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for &color in &[FrightenedColor::Blue, FrightenedColor::White] {
|
|
||||||
for frame in 0..2 {
|
|
||||||
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Frightened(color, frame)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maze sprites
|
|
||||||
for i in 0..=34 {
|
|
||||||
sprites_to_test.push(GameSprite::Maze(MazeSprite::Tile(i)));
|
|
||||||
}
|
|
||||||
sprites_to_test.push(GameSprite::Maze(MazeSprite::Pellet));
|
|
||||||
sprites_to_test.push(GameSprite::Maze(MazeSprite::Energizer));
|
|
||||||
|
|
||||||
for sprite in sprites_to_test {
|
|
||||||
let path = sprite.to_path();
|
|
||||||
assert!(
|
|
||||||
ATLAS_FRAMES.contains_key(&path),
|
|
||||||
"Sprite path '{}' does not exist in the atlas.",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_sprite_paths_do_not_exist() {
|
|
||||||
let invalid_sprites = vec![
|
|
||||||
// An invalid Pac-Man dying frame
|
|
||||||
GameSprite::Pacman(PacmanSprite::Dying(99)),
|
|
||||||
// An invalid maze tile
|
|
||||||
GameSprite::Maze(MazeSprite::Tile(99)),
|
|
||||||
];
|
|
||||||
|
|
||||||
for sprite in invalid_sprites {
|
|
||||||
let path = sprite.to_path();
|
|
||||||
assert!(
|
|
||||||
!ATLAS_FRAMES.contains_key(&path),
|
|
||||||
"Invalid sprite path '{}' was found in the atlas, but it should not exist.",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user