mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-07 01:15:48 -06:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a6ee7684 | ||
|
|
d84f0c831e | ||
|
|
ae19ca1795 | ||
|
|
abf341d753 | ||
|
|
7b6dad0c74 | ||
|
|
5563b64044 | ||
|
|
cb691b0907 | ||
|
|
ce8ea347e1 | ||
|
|
afae3c5e7b | ||
|
|
4f7902fc50 | ||
|
|
2a2cca675a | ||
|
|
f3a6b72931 |
@@ -7,6 +7,8 @@ rustflags = [
|
|||||||
]
|
]
|
||||||
runner = "node"
|
runner = "node"
|
||||||
|
|
||||||
|
# despite being semantically identical to `target_os = "linux"`, the `cfg(linux)` syntax is not supported here. Who knows why...
|
||||||
|
# https://github.com/Xevion/Pac-Man/actions/runs/17596477856
|
||||||
[target.'cfg(target_os = "linux")']
|
[target.'cfg(target_os = "linux")']
|
||||||
rustflags = [
|
rustflags = [
|
||||||
# Manually link zlib.
|
# Manually link zlib.
|
||||||
|
|||||||
@@ -41,17 +41,3 @@ repos:
|
|||||||
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.78.5"
|
version = "0.79.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pacman"
|
name = "pacman"
|
||||||
version = "0.78.5"
|
version = "0.79.0"
|
||||||
authors = ["Xevion"]
|
authors = ["Xevion"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86.0"
|
rust-version = "1.86.0"
|
||||||
@@ -40,7 +40,7 @@ num-width = "0.1.0"
|
|||||||
phf = { version = "0.13.1", features = ["macros"] }
|
phf = { version = "0.13.1", features = ["macros"] }
|
||||||
|
|
||||||
# Windows-specific dependencies
|
# Windows-specific dependencies
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
# Used for customizing console output on Windows; both are required due to the `windows` crate having poor Result handling with `GetStdHandle`.
|
# Used for customizing console output on Windows; both are required due to the `windows` crate having poor Result handling with `GetStdHandle`.
|
||||||
windows = { version = "0.62.0", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
windows = { version = "0.62.0", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||||
windows-sys = { version = "0.61.0", features = ["Win32_System_Console"] }
|
windows-sys = { version = "0.61.0", features = ["Win32_System_Console"] }
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
<!-- markdownlint-disable MD033 -->
|
||||||
|
<!-- markdownlint-disable MD041 -->
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/repo/banner.png" alt="Pac-Man Banner Screenshot">
|
<img src="assets/repo/banner.png" alt="Pac-Man Banner Screenshot">
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +16,6 @@
|
|||||||
[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://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
|
||||||
[badge-online-demo]: https://img.shields.io/badge/Online%20Demo-Click%20Me!-brightgreen
|
[badge-online-demo]: https://img.shields.io/badge/Online%20Demo-Click%20Me!-brightgreen
|
||||||
[banner-image]: assets/repo/banner.png
|
|
||||||
[justforfunnoreally]: https://justforfunnoreally.dev
|
[justforfunnoreally]: https://justforfunnoreally.dev
|
||||||
[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
|
||||||
|
|||||||
2
STORY.md
2
STORY.md
@@ -412,8 +412,8 @@ The bigger downside was that I had to toss out almost all the existing code for
|
|||||||
|
|
||||||
This ended up being okay though, as I was able to clean up a lot of gross code, and the system ended up being easier to work with by comparison.
|
This ended up being okay though, as I was able to clean up a lot of gross code, and the system ended up being easier to work with by comparison.
|
||||||
|
|
||||||
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
|
|
||||||
[code-review-thumbnail]: https://img.youtube.com/vi/OKs_JewEeOo/hqdefault.jpg
|
[code-review-thumbnail]: https://img.youtube.com/vi/OKs_JewEeOo/hqdefault.jpg
|
||||||
|
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
|
||||||
[fighting-lifetimes-1]: https://devcry.heiho.net/html/2022/20220709-rust-and-sdl2-fighting-with-lifetimes.html
|
[fighting-lifetimes-1]: https://devcry.heiho.net/html/2022/20220709-rust-and-sdl2-fighting-with-lifetimes.html
|
||||||
[fighting-lifetimes-2]: https://devcry.heiho.net/html/2022/20220716-rust-and-sdl2-fighting-with-lifetimes-2.html
|
[fighting-lifetimes-2]: https://devcry.heiho.net/html/2022/20220716-rust-and-sdl2-fighting-with-lifetimes-2.html
|
||||||
[fighting-lifetimes-3]: https://devcry.heiho.net/html/2022/20220724-rust-and-sdl2-fighting-with-lifetimes-3.html
|
[fighting-lifetimes-3]: https://devcry.heiho.net/html/2022/20220724-rust-and-sdl2-fighting-with-lifetimes-3.html
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -52,10 +52,12 @@ pub mod animation {
|
|||||||
pub const GHOST_EATEN_SPEED: u16 = 6;
|
pub const GHOST_EATEN_SPEED: u16 = 6;
|
||||||
/// Frightened ghost animation speed (ticks per frame at 60 ticks/sec)
|
/// Frightened ghost animation speed (ticks per frame at 60 ticks/sec)
|
||||||
pub const GHOST_FRIGHTENED_SPEED: u16 = 12;
|
pub const GHOST_FRIGHTENED_SPEED: u16 = 12;
|
||||||
|
/// Time in ticks for frightened ghosts to return to normal
|
||||||
/// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS)
|
pub const GHOST_FRIGHTENED_TICKS: u32 = 300;
|
||||||
pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120;
|
/// Time in ticks when frightened ghosts start flashing
|
||||||
|
pub const GHOST_FRIGHTENED_FLASH_START_TICKS: u32 = GHOST_FRIGHTENED_TICKS - 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The size of the canvas, in pixels.
|
/// The size of the canvas, in pixels.
|
||||||
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
||||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE,
|
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub enum AssetError {
|
|||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub enum PlatformError {
|
pub enum PlatformError {
|
||||||
#[error("Console initialization failed: {0}")]
|
#[error("Console initialization failed: {0}")]
|
||||||
|
#[cfg(any(windows, target_os = "emscripten"))]
|
||||||
ConsoleInit(String),
|
ConsoleInit(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use bevy_ecs::{entity::Entity, event::Event};
|
use bevy_ecs::{entity::Entity, event::Event};
|
||||||
|
|
||||||
use crate::map::direction::Direction;
|
use crate::{map::direction::Direction, systems::Ghost};
|
||||||
|
|
||||||
/// Player input commands that trigger specific game actions.
|
/// Player input commands that trigger specific game actions.
|
||||||
///
|
///
|
||||||
@@ -24,15 +24,12 @@ pub enum GameCommand {
|
|||||||
|
|
||||||
/// Global events that flow through the ECS event system to coordinate game behavior.
|
/// Global events that flow through the ECS event system to coordinate game behavior.
|
||||||
///
|
///
|
||||||
/// Events enable loose coupling between systems - input generates commands, collision
|
/// Events enable loose coupling between systems - input generates commands and
|
||||||
/// detection reports overlaps, and various systems respond appropriately without
|
/// various systems respond appropriately without direct dependencies.
|
||||||
/// direct dependencies.
|
|
||||||
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum GameEvent {
|
pub enum GameEvent {
|
||||||
/// Player input command to be processed by relevant game systems
|
/// Player input command to be processed by relevant game systems
|
||||||
Command(GameCommand),
|
Command(GameCommand),
|
||||||
/// Physical overlap detected between two entities requiring gameplay response
|
|
||||||
Collision(Entity, Entity),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<GameCommand> for GameEvent {
|
impl From<GameCommand> for GameEvent {
|
||||||
@@ -44,5 +41,18 @@ impl From<GameCommand> for GameEvent {
|
|||||||
/// Data for requesting stage transitions; processed centrally in stage_system
|
/// Data for requesting stage transitions; processed centrally in stage_system
|
||||||
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum StageTransition {
|
pub enum StageTransition {
|
||||||
GhostEatenPause { ghost_entity: Entity },
|
GhostEatenPause { ghost_entity: Entity, ghost_type: Ghost },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collision triggers for immediate collision handling via observers
|
||||||
|
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CollisionTrigger {
|
||||||
|
/// Pac-Man collided with a ghost
|
||||||
|
GhostCollision {
|
||||||
|
pacman: Entity,
|
||||||
|
ghost: Entity,
|
||||||
|
ghost_type: Ghost,
|
||||||
|
},
|
||||||
|
/// Pac-Man collided with an item
|
||||||
|
ItemCollision { item: Entity },
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/game.rs
93
src/game.rs
@@ -3,23 +3,24 @@
|
|||||||
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
|
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::ops::Not;
|
||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
|
use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
|
||||||
use crate::error::{GameError, GameResult};
|
use crate::error::{GameError, GameResult};
|
||||||
use crate::events::{GameEvent, StageTransition};
|
use crate::events::{CollisionTrigger, GameEvent, StageTransition};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::map::direction::Direction;
|
use crate::map::direction::Direction;
|
||||||
use crate::systems::{
|
use crate::systems::{
|
||||||
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
|
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
|
||||||
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
|
dirty_render_system, eaten_ghost_system, ghost_collision_observer, ghost_movement_system, ghost_state_system,
|
||||||
hud_render_system, item_system, linear_render_system, player_life_sprite_system, present_system, profile,
|
hud_render_system, item_collision_observer, linear_render_system, player_life_sprite_system, present_system, profile,
|
||||||
time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking,
|
time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking,
|
||||||
BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen,
|
BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen,
|
||||||
GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, Hidden, ItemBundle,
|
GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, ItemBundle,
|
||||||
ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, PacmanCollider,
|
ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, PacmanCollider,
|
||||||
PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable,
|
PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable,
|
||||||
ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity,
|
ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
||||||
@@ -46,10 +47,23 @@ use crate::{
|
|||||||
texture::sprite::{AtlasMapper, SpriteAtlas},
|
texture::sprite::{AtlasMapper, SpriteAtlas},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// System set for all gameplay systems to ensure they run after input processing
|
||||||
|
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
||||||
|
enum GameplaySet {
|
||||||
|
/// Gameplay systems that process inputs
|
||||||
|
Input,
|
||||||
|
/// Gameplay systems that update the game state
|
||||||
|
Update,
|
||||||
|
/// Gameplay systems that respond to events
|
||||||
|
Respond,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
enum RenderSet {
|
||||||
Animation,
|
Animation,
|
||||||
|
Draw,
|
||||||
|
Present,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Core game state manager built on the Bevy ECS architecture.
|
/// Core game state manager built on the Bevy ECS architecture.
|
||||||
@@ -148,7 +162,7 @@ impl Game {
|
|||||||
Self::configure_schedule(&mut schedule);
|
Self::configure_schedule(&mut schedule);
|
||||||
|
|
||||||
debug!("Spawning player entity");
|
debug!("Spawning player entity");
|
||||||
world.spawn(player_bundle).insert((Frozen, Hidden));
|
world.spawn(player_bundle).insert((Frozen, Visibility::hidden()));
|
||||||
|
|
||||||
info!("Spawning game entities");
|
info!("Spawning game entities");
|
||||||
Self::spawn_ghosts(&mut world)?;
|
Self::spawn_ghosts(&mut world)?;
|
||||||
@@ -376,6 +390,7 @@ impl Game {
|
|||||||
EventRegistry::register_event::<GameEvent>(world);
|
EventRegistry::register_event::<GameEvent>(world);
|
||||||
EventRegistry::register_event::<AudioEvent>(world);
|
EventRegistry::register_event::<AudioEvent>(world);
|
||||||
EventRegistry::register_event::<StageTransition>(world);
|
EventRegistry::register_event::<StageTransition>(world);
|
||||||
|
EventRegistry::register_event::<CollisionTrigger>(world);
|
||||||
|
|
||||||
world.add_observer(
|
world.add_observer(
|
||||||
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
|
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
|
||||||
@@ -384,6 +399,9 @@ impl Game {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
world.add_observer(ghost_collision_observer);
|
||||||
|
world.add_observer(item_collision_observer);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -443,8 +461,6 @@ impl Game {
|
|||||||
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 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);
|
||||||
let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system);
|
let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system);
|
||||||
@@ -457,13 +473,6 @@ impl Game {
|
|||||||
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
|
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
|
||||||
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system);
|
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system);
|
||||||
|
|
||||||
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
|
||||||
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
|
// Input system should always run to prevent SDL event pump from blocking
|
||||||
let input_systems = (
|
let input_systems = (
|
||||||
input_system.run_if(|mut local: Local<u8>| {
|
input_system.run_if(|mut local: Local<u8>| {
|
||||||
@@ -475,33 +484,49 @@ impl Game {
|
|||||||
)
|
)
|
||||||
.chain();
|
.chain();
|
||||||
|
|
||||||
let gameplay_systems = (
|
// .run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
|
||||||
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
|
|
||||||
|
schedule
|
||||||
|
.add_systems((
|
||||||
|
input_systems.in_set(GameplaySet::Input),
|
||||||
|
time_to_live_system.before(GameplaySet::Update),
|
||||||
|
(
|
||||||
|
player_movement_system,
|
||||||
|
player_tunnel_slowdown_system,
|
||||||
|
ghost_movement_system,
|
||||||
eaten_ghost_system,
|
eaten_ghost_system,
|
||||||
(collision_system, ghost_collision_system, item_system).chain(),
|
collision_system,
|
||||||
unified_ghost_state_system,
|
unified_ghost_state_system,
|
||||||
)
|
)
|
||||||
.chain()
|
.in_set(GameplaySet::Update),
|
||||||
.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((
|
|
||||||
time_to_live_system,
|
|
||||||
stage_system,
|
|
||||||
input_systems,
|
|
||||||
gameplay_systems,
|
|
||||||
(
|
(
|
||||||
dirty_render_system,
|
blinking_system,
|
||||||
|
directional_render_system,
|
||||||
|
linear_render_system,
|
||||||
|
player_life_sprite_system,
|
||||||
|
)
|
||||||
|
.in_set(RenderSet::Animation),
|
||||||
|
stage_system.in_set(GameplaySet::Respond),
|
||||||
|
(
|
||||||
|
(|mut dirty: ResMut<RenderDirty>, score: Res<ScoreResource>, stage: Res<GameStage>| {
|
||||||
|
dirty.0 = score.is_changed() || stage.is_changed();
|
||||||
|
}),
|
||||||
|
dirty_render_system.run_if(|dirty: Res<RenderDirty>| dirty.0.not()),
|
||||||
combined_render_system,
|
combined_render_system,
|
||||||
hud_render_system,
|
hud_render_system,
|
||||||
player_life_sprite_system,
|
|
||||||
touch_ui_render_system,
|
touch_ui_render_system,
|
||||||
present_system,
|
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(RenderSet::Animation),
|
.in_set(RenderSet::Draw),
|
||||||
audio_system,
|
(present_system, audio_system).chain().in_set(RenderSet::Present),
|
||||||
|
))
|
||||||
|
.configure_sets((
|
||||||
|
GameplaySet::Input,
|
||||||
|
GameplaySet::Update,
|
||||||
|
GameplaySet::Respond,
|
||||||
|
RenderSet::Animation,
|
||||||
|
RenderSet::Draw,
|
||||||
|
RenderSet::Present,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +626,7 @@ impl Game {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let entity = world.spawn(ghost).insert((Frozen, Hidden)).id();
|
let entity = world.spawn(ghost).insert((Frozen, Visibility::hidden())).id();
|
||||||
trace!(ghost = ?ghost_type, entity = ?entity, start_node, "Spawned ghost entity");
|
trace!(ghost = ?ghost_type, entity = ?entity, start_node, "Spawned ghost entity");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,11 +175,6 @@ impl Map {
|
|||||||
remaining_distance: distance / 2.0,
|
remaining_distance: distance / 2.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::warn!(
|
|
||||||
fruit_spawn_position = ?fruit_spawn_position,
|
|
||||||
"Fruit spawn position found"
|
|
||||||
);
|
|
||||||
|
|
||||||
let start_positions = NodePositions {
|
let start_positions = NodePositions {
|
||||||
pacman: grid_to_node[&start_pos],
|
pacman: grid_to_node[&start_pos],
|
||||||
blinky: house_entrance_node_id,
|
blinky: house_entrance_node_id,
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
#[cfg(not(target_os = "emscripten"))]
|
#[cfg(not(target_os = "emscripten"))]
|
||||||
mod desktop;
|
mod desktop;
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
#[cfg(not(target_os = "emscripten"))]
|
||||||
pub mod tracing_buffer;
|
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
|
||||||
pub use desktop::*;
|
pub use desktop::*;
|
||||||
|
|
||||||
|
/// Tracing buffer is only used on Windows.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod tracing_buffer;
|
||||||
|
|
||||||
#[cfg(target_os = "emscripten")]
|
#[cfg(target_os = "emscripten")]
|
||||||
pub use emscripten::*;
|
pub use emscripten::*;
|
||||||
#[cfg(target_os = "emscripten")]
|
#[cfg(target_os = "emscripten")]
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
component::Component,
|
component::Component,
|
||||||
entity::Entity,
|
|
||||||
query::{Has, With},
|
query::{Has, With},
|
||||||
system::{Commands, Query, Res},
|
system::{Query, Res},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::systems::{DeltaTime, Frozen, Hidden, Renderable};
|
use crate::systems::{DeltaTime, Frozen, Renderable, Visibility};
|
||||||
|
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct Blinking {
|
pub struct Blinking {
|
||||||
@@ -28,18 +27,11 @@ impl Blinking {
|
|||||||
/// accumulating ticks and toggling visibility when the specified interval is reached.
|
/// accumulating ticks and toggling visibility when the specified interval is reached.
|
||||||
/// Uses integer arithmetic for deterministic behavior.
|
/// Uses integer arithmetic for deterministic behavior.
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn blinking_system(
|
pub fn blinking_system(time: Res<DeltaTime>, mut query: Query<(&mut Blinking, &mut Visibility, Has<Frozen>), With<Renderable>>) {
|
||||||
mut commands: Commands,
|
for (mut blinking, mut visibility, frozen) in query.iter_mut() {
|
||||||
time: Res<DeltaTime>,
|
// If the entity is frozen, blinking is disabled and the entity is made visible
|
||||||
mut query: Query<(Entity, &mut Blinking, Has<Hidden>, Has<Frozen>), With<Renderable>>,
|
|
||||||
) {
|
|
||||||
for (entity, mut blinking, hidden, frozen) in query.iter_mut() {
|
|
||||||
// If the entity is frozen, blinking is disabled and the entity is unhidden (if it was hidden)
|
|
||||||
if frozen {
|
if frozen {
|
||||||
if hidden {
|
visibility.show();
|
||||||
commands.entity(entity).remove::<Hidden>();
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +41,7 @@ pub fn blinking_system(
|
|||||||
// Handle zero interval case (immediate toggling)
|
// Handle zero interval case (immediate toggling)
|
||||||
if blinking.interval_ticks == 0 {
|
if blinking.interval_ticks == 0 {
|
||||||
if time.ticks > 0 {
|
if time.ticks > 0 {
|
||||||
if hidden {
|
visibility.toggle();
|
||||||
commands.entity(entity).remove::<Hidden>();
|
|
||||||
} else {
|
|
||||||
commands.entity(entity).insert(Hidden);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -69,14 +57,10 @@ pub fn blinking_system(
|
|||||||
// Update the timer to the remainder after complete intervals
|
// Update the timer to the remainder after complete intervals
|
||||||
blinking.tick_timer %= blinking.interval_ticks;
|
blinking.tick_timer %= blinking.interval_ticks;
|
||||||
|
|
||||||
// Toggle the Hidden component for each complete interval
|
// Toggle the visibility for each complete interval
|
||||||
// Since toggling twice is a no-op, we only need to toggle if the count is odd
|
// Since toggling twice is a no-op, we only need to toggle if the count is odd
|
||||||
if complete_intervals % 2 == 1 {
|
if complete_intervals % 2 == 1 {
|
||||||
if hidden {
|
visibility.toggle();
|
||||||
commands.entity(entity).remove::<Hidden>();
|
|
||||||
} else {
|
|
||||||
commands.entity(entity).insert(Hidden);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
component::Component,
|
component::Component,
|
||||||
query::{Has, Or, With, Without},
|
query::{Has, Or, With, Without},
|
||||||
resource::Resource,
|
|
||||||
system::{Query, Res},
|
system::{Query, Res},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
systems::{DeltaTime, Dying, Frozen, Position, Renderable, Velocity},
|
systems::{DeltaTime, Dying, Frozen, LinearAnimation, Looping, Position, Renderable, Velocity},
|
||||||
texture::animated::{DirectionalTiles, TileSequence},
|
texture::animated::DirectionalTiles,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Directional animation component with shared timing across all directions
|
/// Directional animation component with shared timing across all directions
|
||||||
@@ -33,48 +32,21 @@ 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)
|
|
||||||
#[derive(Component, Resource, Clone)]
|
|
||||||
pub struct LinearAnimation {
|
|
||||||
pub tiles: TileSequence,
|
|
||||||
pub current_frame: usize,
|
|
||||||
pub time_bank: u16,
|
|
||||||
pub frame_duration: u16,
|
|
||||||
pub finished: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinearAnimation {
|
|
||||||
/// Creates a new linear animation with the given tiles and frame duration
|
|
||||||
pub fn new(tiles: TileSequence, frame_duration: u16) -> Self {
|
|
||||||
Self {
|
|
||||||
tiles,
|
|
||||||
current_frame: 0,
|
|
||||||
time_bank: 0,
|
|
||||||
frame_duration,
|
|
||||||
finished: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates directional animated entities with synchronized timing across directions.
|
/// Updates directional animated entities with synchronized timing across directions.
|
||||||
///
|
///
|
||||||
/// This runs before the render system to update sprites based on current direction and movement state.
|
/// This runs before the render system to update sprites based on current direction and movement state.
|
||||||
/// 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, Has<Frozen>)>,
|
||||||
) {
|
) {
|
||||||
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
|
||||||
|
|
||||||
for (position, velocity, mut anim, mut renderable) in query.iter_mut() {
|
for (position, velocity, mut anim, mut renderable, frozen) in query.iter_mut() {
|
||||||
let stopped = matches!(position, Position::Stopped { .. });
|
let stopped = matches!(position, Position::Stopped { .. });
|
||||||
|
|
||||||
// Only tick animation when moving to preserve stopped frame
|
// Only tick animation when moving to preserve stopped frame
|
||||||
if !stopped {
|
if !stopped && !frozen {
|
||||||
// Tick shared animation state
|
// Tick shared animation state
|
||||||
anim.time_bank += ticks;
|
anim.time_bank += ticks;
|
||||||
while anim.time_bank >= anim.frame_duration {
|
while anim.time_bank >= anim.frame_duration {
|
||||||
30
src/systems/animation/linear.rs
Normal file
30
src/systems/animation/linear.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use crate::texture::animated::TileSequence;
|
||||||
|
use bevy_ecs::component::Component;
|
||||||
|
use bevy_ecs::resource::Resource;
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
#[derive(Component, Resource, Clone)]
|
||||||
|
pub struct LinearAnimation {
|
||||||
|
pub tiles: TileSequence,
|
||||||
|
pub current_frame: usize,
|
||||||
|
pub time_bank: u16,
|
||||||
|
pub frame_duration: u16,
|
||||||
|
pub finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinearAnimation {
|
||||||
|
/// Creates a new linear animation with the given tiles and frame duration
|
||||||
|
pub fn new(tiles: TileSequence, frame_duration: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
tiles,
|
||||||
|
current_frame: 0,
|
||||||
|
time_bank: 0,
|
||||||
|
frame_duration,
|
||||||
|
finished: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/systems/animation/mod.rs
Normal file
7
src/systems/animation/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod blinking;
|
||||||
|
mod directional;
|
||||||
|
mod linear;
|
||||||
|
|
||||||
|
pub use self::blinking::*;
|
||||||
|
pub use self::directional::*;
|
||||||
|
pub use self::linear::*;
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
component::Component,
|
component::Component,
|
||||||
entity::Entity,
|
entity::Entity,
|
||||||
event::{EventReader, EventWriter},
|
event::EventWriter,
|
||||||
|
observer::Trigger,
|
||||||
query::With,
|
query::With,
|
||||||
system::{Commands, Query, Res, ResMut, Single},
|
system::{Commands, Query, Res, ResMut},
|
||||||
};
|
};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use crate::events::{GameEvent, StageTransition};
|
use crate::{
|
||||||
use crate::map::builder::Map;
|
constants,
|
||||||
use crate::systems::{movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled, ScoreResource};
|
systems::{movement::Position, AudioEvent, DyingSequence, GameStage, Ghost, ScoreResource, SpawnTrigger},
|
||||||
|
};
|
||||||
use crate::{error::GameError, systems::GhostState};
|
use crate::{error::GameError, systems::GhostState};
|
||||||
|
use crate::{
|
||||||
|
events::{CollisionTrigger, StageTransition},
|
||||||
|
systems::PelletCount,
|
||||||
|
};
|
||||||
|
use crate::{map::builder::Map, systems::EntityType};
|
||||||
|
|
||||||
/// A component for defining the collision area of an entity.
|
/// A component for defining the collision area of an entity.
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
@@ -55,11 +62,11 @@ pub fn check_collision(
|
|||||||
Ok(collider1.collides_with(collider2.size, distance))
|
Ok(collider1.collides_with(collider2.size, distance))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detects overlapping entities and generates collision events for gameplay systems.
|
/// Detects overlapping entities and triggers collision observers immediately.
|
||||||
///
|
///
|
||||||
/// Performs distance-based collision detection between Pac-Man and collectible items
|
/// Performs distance-based collision detection between Pac-Man and collectible items
|
||||||
/// using each entity's position and collision radius. When entities overlap, emits
|
/// using each entity's position and collision radius. When entities overlap, triggers
|
||||||
/// a `GameEvent::Collision` for the item system to handle scoring and removal.
|
/// collision observers for immediate handling without race conditions.
|
||||||
/// Collision detection accounts for both entities being in motion and supports
|
/// Collision detection accounts for both entities being in motion and supports
|
||||||
/// circular collision boundaries for accurate gameplay feel.
|
/// circular collision boundaries for accurate gameplay feel.
|
||||||
///
|
///
|
||||||
@@ -70,8 +77,8 @@ 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>>,
|
||||||
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
|
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
|
||||||
ghost_query: Query<(Entity, &Position, &Collider), With<GhostCollider>>,
|
ghost_query: Query<(Entity, &Position, &Collider, &Ghost, &GhostState), With<GhostCollider>>,
|
||||||
mut events: EventWriter<GameEvent>,
|
mut commands: Commands,
|
||||||
mut errors: EventWriter<GameError>,
|
mut errors: EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
// Check PACMAN × ITEM collisions
|
// Check PACMAN × ITEM collisions
|
||||||
@@ -80,8 +87,8 @@ pub fn collision_system(
|
|||||||
match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
|
match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
|
||||||
Ok(colliding) => {
|
Ok(colliding) => {
|
||||||
if colliding {
|
if colliding {
|
||||||
trace!(pacman_entity = ?pacman_entity, item_entity = ?item_entity, "Item collision detected");
|
trace!("Item collision detected");
|
||||||
events.write(GameEvent::Collision(pacman_entity, item_entity));
|
commands.trigger(CollisionTrigger::ItemCollision { item: item_entity });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -94,13 +101,19 @@ pub fn collision_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check PACMAN × GHOST collisions
|
// Check PACMAN × GHOST collisions
|
||||||
for (ghost_entity, ghost_pos, ghost_collider) in ghost_query.iter() {
|
for (ghost_entity, ghost_pos, ghost_collider, ghost, ghost_state) in ghost_query.iter() {
|
||||||
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
|
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
|
||||||
Ok(colliding) => {
|
Ok(colliding) => {
|
||||||
if colliding {
|
if !colliding || matches!(*ghost_state, GhostState::Eyes) {
|
||||||
trace!(pacman_entity = ?pacman_entity, ghost_entity = ?ghost_entity, "Ghost collision detected");
|
continue;
|
||||||
events.write(GameEvent::Collision(pacman_entity, ghost_entity));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trace!(ghost = ?ghost, "Ghost collision detected");
|
||||||
|
commands.trigger(CollisionTrigger::GhostCollision {
|
||||||
|
pacman: pacman_entity,
|
||||||
|
ghost: ghost_entity,
|
||||||
|
ghost_type: *ghost,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
errors.write(GameError::InvalidState(format!(
|
errors.write(GameError::InvalidState(format!(
|
||||||
@@ -113,57 +126,126 @@ pub fn collision_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Observer for handling ghost collisions immediately when they occur
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn ghost_collision_system(
|
pub fn ghost_collision_observer(
|
||||||
mut commands: Commands,
|
trigger: Trigger<CollisionTrigger>,
|
||||||
mut collision_events: EventReader<GameEvent>,
|
|
||||||
mut stage_events: EventWriter<StageTransition>,
|
mut stage_events: EventWriter<StageTransition>,
|
||||||
mut score: ResMut<ScoreResource>,
|
mut score: ResMut<ScoreResource>,
|
||||||
mut game_state: ResMut<GameStage>,
|
mut game_state: ResMut<GameStage>,
|
||||||
player: Single<Entity, With<PlayerControlled>>,
|
|
||||||
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>,
|
||||||
) {
|
) {
|
||||||
for event in collision_events.read() {
|
if let CollisionTrigger::GhostCollision {
|
||||||
if let GameEvent::Collision(entity1, entity2) = event {
|
pacman: _pacman,
|
||||||
// Check if one is Pacman and the other is a ghost
|
ghost,
|
||||||
let (pacman_entity, ghost_entity) = if *entity1 == *player && ghost_query.get(*entity2).is_ok() {
|
ghost_type,
|
||||||
(*entity1, *entity2)
|
} = *trigger
|
||||||
} else if *entity2 == *player && ghost_query.get(*entity1).is_ok() {
|
{
|
||||||
(*entity2, *entity1)
|
// Check if Pac-Man is already dying
|
||||||
} else {
|
if matches!(*game_state, GameStage::PlayerDying(_)) {
|
||||||
continue;
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
// Check if the ghost is frightened
|
// Check if the ghost is frightened
|
||||||
if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) {
|
if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost) {
|
||||||
if let Ok(ghost_state) = ghost_state_query.get_mut(ghost_ent) {
|
|
||||||
// Check if ghost is in frightened state
|
// Check if ghost is in frightened state
|
||||||
if matches!(*ghost_state, GhostState::Frightened { .. }) {
|
if matches!(*ghost_state, GhostState::Frightened { .. }) {
|
||||||
// Pac-Man eats the ghost
|
// Pac-Man eats the ghost
|
||||||
// Add score (200 points per ghost eaten)
|
// Add score (200 points per ghost eaten)
|
||||||
debug!(ghost_entity = ?ghost_ent, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
|
debug!(ghost = ?ghost_type, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
|
||||||
score.0 += 200;
|
score.0 += 200;
|
||||||
|
|
||||||
|
*ghost_state = GhostState::Eyes;
|
||||||
|
|
||||||
// Enter short pause to show bonus points, hide ghost, then set Eyes after pause
|
// Enter short pause to show bonus points, hide ghost, then set Eyes after pause
|
||||||
// Request transition via event so stage_system can process it
|
// Request transition via event so stage_system can process it
|
||||||
stage_events.write(StageTransition::GhostEatenPause { ghost_entity: ghost_ent });
|
stage_events.write(StageTransition::GhostEatenPause {
|
||||||
|
ghost_entity: ghost,
|
||||||
|
ghost_type,
|
||||||
|
});
|
||||||
|
|
||||||
// Play eat sound
|
// Play eat sound
|
||||||
events.write(AudioEvent::PlayEat);
|
events.write(AudioEvent::PlayEat);
|
||||||
} else if matches!(*ghost_state, GhostState::Normal) {
|
} else if matches!(*ghost_state, GhostState::Normal) {
|
||||||
// Pac-Man dies
|
// Pac-Man dies
|
||||||
warn!(ghost_entity = ?ghost_ent, "Pacman hit by normal ghost, player dies");
|
warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player dies");
|
||||||
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
|
*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);
|
events.write(AudioEvent::StopAll);
|
||||||
} else {
|
} else {
|
||||||
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
|
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observer for handling item collisions immediately when they occur
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn item_collision_observer(
|
||||||
|
trigger: Trigger<CollisionTrigger>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut score: ResMut<ScoreResource>,
|
||||||
|
mut pellet_count: ResMut<PelletCount>,
|
||||||
|
item_query: Query<(Entity, &EntityType, &Position), With<ItemCollider>>,
|
||||||
|
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
|
||||||
|
mut events: EventWriter<AudioEvent>,
|
||||||
|
) {
|
||||||
|
if let CollisionTrigger::ItemCollision { item } = *trigger {
|
||||||
|
// Get the item type and update score
|
||||||
|
if let Ok((item_ent, entity_type, position)) = item_query.get(item) {
|
||||||
|
if let Some(score_value) = entity_type.score_value() {
|
||||||
|
trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player");
|
||||||
|
score.0 += score_value;
|
||||||
|
|
||||||
|
// Remove the collected item
|
||||||
|
commands.entity(item_ent).despawn();
|
||||||
|
|
||||||
|
// Track pellet consumption for fruit spawning
|
||||||
|
if *entity_type == EntityType::Pellet {
|
||||||
|
pellet_count.0 += 1;
|
||||||
|
trace!(pellet_count = pellet_count.0, "Pellet consumed");
|
||||||
|
|
||||||
|
// Check if we should spawn a fruit
|
||||||
|
if pellet_count.0 == 5 || pellet_count.0 == 170 {
|
||||||
|
debug!(pellet_count = pellet_count.0, "Fruit spawn milestone reached");
|
||||||
|
commands.trigger(SpawnTrigger::Fruit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger bonus points effect if a fruit is collected
|
||||||
|
if matches!(*entity_type, EntityType::Fruit(_)) {
|
||||||
|
commands.trigger(SpawnTrigger::Bonus {
|
||||||
|
position: *position,
|
||||||
|
value: entity_type.score_value().unwrap(),
|
||||||
|
ttl: 60 * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger audio if appropriate
|
||||||
|
if entity_type.is_collectible() {
|
||||||
|
events.write(AudioEvent::PlayEat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make non-eaten ghosts frightened when power pellet is collected
|
||||||
|
if matches!(*entity_type, EntityType::PowerPellet) {
|
||||||
|
debug!(
|
||||||
|
duration_ticks = constants::animation::GHOST_FRIGHTENED_TICKS,
|
||||||
|
"Power pellet collected, frightening ghosts"
|
||||||
|
);
|
||||||
|
for mut ghost_state in ghost_query.iter_mut() {
|
||||||
|
if matches!(*ghost_state, GhostState::Normal) {
|
||||||
|
*ghost_state = GhostState::new_frightened(
|
||||||
|
constants::animation::GHOST_FRIGHTENED_TICKS,
|
||||||
|
constants::animation::GHOST_FRIGHTENED_FLASH_START_TICKS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
frightened_count = ghost_query.iter().count(),
|
||||||
|
"Ghosts set to frightened state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ impl Default for MovementModifiers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Tag component for entities that should be frozen during startup
|
/// Tag component for entities that should be frozen during startup
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Frozen;
|
pub struct Frozen;
|
||||||
|
|
||||||
/// Component for HUD life sprite entities.
|
/// Component for HUD life sprite entities.
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ use bevy_ecs::system::{Commands, Query, Res};
|
|||||||
use rand::seq::IndexedRandom;
|
use rand::seq::IndexedRandom;
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
/// Tag component for eaten ghosts
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct Eaten;
|
|
||||||
|
|
||||||
/// Tag component for Pac-Man during his death animation.
|
/// 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.
|
/// 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)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
@@ -61,7 +57,7 @@ impl Ghost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum GhostState {
|
pub enum GhostState {
|
||||||
/// Normal ghost behavior - chasing Pac-Man
|
/// Normal ghost behavior - chasing Pac-Man
|
||||||
Normal,
|
Normal,
|
||||||
@@ -258,7 +254,7 @@ pub fn ghost_movement_system(
|
|||||||
pub fn eaten_ghost_system(
|
pub fn eaten_ghost_system(
|
||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
delta_time: Res<DeltaTime>,
|
delta_time: Res<DeltaTime>,
|
||||||
mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState)>,
|
mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState), Without<Frozen>>,
|
||||||
) {
|
) {
|
||||||
for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() {
|
for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() {
|
||||||
// Only process ghosts that are in Eyes state
|
// Only process ghosts that are in Eyes state
|
||||||
|
|||||||
89
src/systems/hud/lives.rs
Normal file
89
src/systems/hud/lives.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
use crate::constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE};
|
||||||
|
use crate::error::GameError;
|
||||||
|
use crate::map::direction::Direction;
|
||||||
|
use crate::systems::{PixelPosition, PlayerLife, PlayerLives, Renderable};
|
||||||
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
use crate::texture::sprites::{GameSprite, PacmanSprite};
|
||||||
|
use bevy_ecs::entity::Entity;
|
||||||
|
use bevy_ecs::event::EventWriter;
|
||||||
|
use bevy_ecs::system::{Commands, NonSendMut, Query, Res};
|
||||||
|
use glam::Vec2;
|
||||||
|
|
||||||
|
/// Calculates the pixel position for a life sprite based on its index
|
||||||
|
fn calculate_life_sprite_position(index: u32) -> Vec2 {
|
||||||
|
let start_x = CELL_SIZE * 2; // 2 cells from left
|
||||||
|
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
|
||||||
|
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
|
||||||
|
|
||||||
|
let x = start_x + ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
|
||||||
|
let y = start_y - CELL_SIZE / 2;
|
||||||
|
|
||||||
|
Vec2::new((x + CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that manages player life sprite entities.
|
||||||
|
/// Spawns and despawns life sprite entities based on changes to PlayerLives resource.
|
||||||
|
/// Each life sprite is positioned based on its index (0, 1, 2, etc. from left to right).
|
||||||
|
pub fn player_life_sprite_system(
|
||||||
|
mut commands: Commands,
|
||||||
|
atlas: NonSendMut<SpriteAtlas>,
|
||||||
|
current_life_sprites: Query<(Entity, &PlayerLife)>,
|
||||||
|
player_lives: Res<PlayerLives>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
let displayed_lives = player_lives.0.saturating_sub(1);
|
||||||
|
|
||||||
|
// Get current life sprite entities, sorted by index
|
||||||
|
let mut current_sprites: Vec<_> = current_life_sprites.iter().collect();
|
||||||
|
current_sprites.sort_by_key(|(_, life)| life.index);
|
||||||
|
let current_count = current_sprites.len() as u8;
|
||||||
|
|
||||||
|
// Calculate the difference
|
||||||
|
let diff = (displayed_lives as i8) - (current_count as i8);
|
||||||
|
|
||||||
|
match diff.cmp(&0) {
|
||||||
|
// Ignore when the number of lives displayed is correct
|
||||||
|
Ordering::Equal => {}
|
||||||
|
// Spawn new life sprites
|
||||||
|
Ordering::Greater => {
|
||||||
|
let life_sprite = match atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path()) {
|
||||||
|
Ok(sprite) => sprite,
|
||||||
|
Err(e) => {
|
||||||
|
errors.write(e.into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for i in 0..diff {
|
||||||
|
let position = calculate_life_sprite_position(i as u32);
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
PlayerLife { index: i as u32 },
|
||||||
|
Renderable {
|
||||||
|
sprite: life_sprite,
|
||||||
|
layer: 255, // High layer to render on top
|
||||||
|
},
|
||||||
|
PixelPosition {
|
||||||
|
pixel_position: position,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove excess life sprites (highest indices first)
|
||||||
|
Ordering::Less => {
|
||||||
|
let to_remove = diff.unsigned_abs();
|
||||||
|
let sprites_to_remove: Vec<_> = current_sprites
|
||||||
|
.iter()
|
||||||
|
.rev() // Start from highest index
|
||||||
|
.take(to_remove as usize)
|
||||||
|
.map(|(entity, _)| *entity)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for entity in sprites_to_remove {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/systems/hud/mod.rs
Normal file
7
src/systems/hud/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod lives;
|
||||||
|
pub mod score;
|
||||||
|
pub mod touch;
|
||||||
|
|
||||||
|
pub use self::lives::*;
|
||||||
|
pub use self::score::*;
|
||||||
|
pub use self::touch::*;
|
||||||
86
src/systems/hud/score.rs
Normal file
86
src/systems/hud/score.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use crate::constants;
|
||||||
|
use crate::error::{GameError, TextureError};
|
||||||
|
use crate::systems::{BackbufferResource, GameStage, ScoreResource, StartupSequence};
|
||||||
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
use crate::texture::text::TextTexture;
|
||||||
|
use bevy_ecs::event::EventWriter;
|
||||||
|
use bevy_ecs::system::{NonSendMut, Res};
|
||||||
|
use sdl2::pixels::Color;
|
||||||
|
use sdl2::render::Canvas;
|
||||||
|
use sdl2::video::Window;
|
||||||
|
|
||||||
|
/// Renders the HUD (score, lives, etc.) on top of the game.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn hud_render_system(
|
||||||
|
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||||
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
|
mut atlas: NonSendMut<SpriteAtlas>,
|
||||||
|
score: Res<ScoreResource>,
|
||||||
|
stage: Res<GameStage>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
||||||
|
let mut text_renderer = TextTexture::new(1.0);
|
||||||
|
|
||||||
|
// Render lives and high score text in white
|
||||||
|
let lives_text = "1UP HIGH SCORE ";
|
||||||
|
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
|
||||||
|
|
||||||
|
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render score text
|
||||||
|
let score_text = format!("{:02}", score.0);
|
||||||
|
let score_offset = 7 - (score_text.len() as i32);
|
||||||
|
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
||||||
|
|
||||||
|
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render high score text
|
||||||
|
let high_score_text = format!("{:02}", score.0);
|
||||||
|
let high_score_offset = 17 - (high_score_text.len() as i32);
|
||||||
|
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
||||||
|
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
|
||||||
|
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((constants::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
|
||||||
|
if matches!(
|
||||||
|
*stage,
|
||||||
|
GameStage::Starting(StartupSequence::TextOnly { .. })
|
||||||
|
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
|
||||||
|
) {
|
||||||
|
let ready_text = "READY!";
|
||||||
|
let ready_width = text_renderer.text_width(ready_text);
|
||||||
|
let ready_position = glam::UVec2::new((constants::CANVAS_SIZE.x - ready_width) / 2, 160);
|
||||||
|
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
|
||||||
|
let player_one_text = "PLAYER ONE";
|
||||||
|
let player_one_width = text_renderer.text_width(player_one_text);
|
||||||
|
let player_one_position = glam::UVec2::new((constants::CANVAS_SIZE.x - player_one_width) / 2, 113);
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
|
||||||
|
{
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
81
src/systems/hud/touch.rs
Normal file
81
src/systems/hud/touch.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use crate::error::{GameError, TextureError};
|
||||||
|
use crate::systems::{BackbufferResource, TouchState};
|
||||||
|
use bevy_ecs::event::EventWriter;
|
||||||
|
use bevy_ecs::system::{NonSendMut, Res};
|
||||||
|
use sdl2::pixels::Color;
|
||||||
|
use sdl2::rect::Point;
|
||||||
|
use sdl2::render::{BlendMode, Canvas};
|
||||||
|
use sdl2::video::Window;
|
||||||
|
|
||||||
|
/// Renders touch UI overlay for mobile/testing.
|
||||||
|
pub fn touch_ui_render_system(
|
||||||
|
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||||
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
|
touch_state: Res<TouchState>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
if let Some(ref touch_data) = touch_state.active_touch {
|
||||||
|
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
||||||
|
// Set blend mode for transparency
|
||||||
|
canvas.set_blend_mode(BlendMode::Blend);
|
||||||
|
|
||||||
|
// Draw semi-transparent circle at touch start position
|
||||||
|
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
|
||||||
|
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
|
||||||
|
|
||||||
|
// Draw a simple circle by drawing filled rectangles (basic approach)
|
||||||
|
let radius = 30;
|
||||||
|
for dy in -radius..=radius {
|
||||||
|
for dx in -radius..=radius {
|
||||||
|
if dx * dx + dy * dy <= radius * radius {
|
||||||
|
let point = Point::new(center.x + dx, center.y + dy);
|
||||||
|
if let Err(e) = canvas.draw_point(point) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw direction indicator if we have a direction
|
||||||
|
if let Some(direction) = touch_data.current_direction {
|
||||||
|
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
|
||||||
|
|
||||||
|
// Draw arrow indicating direction
|
||||||
|
let arrow_length = 40;
|
||||||
|
let (dx, dy) = match direction {
|
||||||
|
crate::map::direction::Direction::Up => (0, -arrow_length),
|
||||||
|
crate::map::direction::Direction::Down => (0, arrow_length),
|
||||||
|
crate::map::direction::Direction::Left => (-arrow_length, 0),
|
||||||
|
crate::map::direction::Direction::Right => (arrow_length, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_point = Point::new(center.x + dx, center.y + dy);
|
||||||
|
if let Err(e) = canvas.draw_line(center, end_point) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw arrowhead (simple approach)
|
||||||
|
let arrow_size = 8;
|
||||||
|
match direction {
|
||||||
|
crate::map::direction::Direction::Up => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
||||||
|
}
|
||||||
|
crate::map::direction::Direction::Down => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
||||||
|
}
|
||||||
|
crate::map::direction::Direction::Left => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
||||||
|
}
|
||||||
|
crate::map::direction::Direction::Right => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
entity::Entity,
|
event::Event,
|
||||||
event::{Event, EventReader, EventWriter},
|
|
||||||
observer::Trigger,
|
observer::Trigger,
|
||||||
query::With,
|
system::{Commands, NonSendMut, Res},
|
||||||
system::{Commands, NonSendMut, Query, Res, ResMut, Single},
|
|
||||||
};
|
};
|
||||||
use strum_macros::IntoStaticStr;
|
use strum_macros::IntoStaticStr;
|
||||||
use tracing::{debug, trace};
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants,
|
constants,
|
||||||
@@ -18,12 +16,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{systems::common::components::EntityType, systems::ItemCollider};
|
||||||
constants::animation::FRIGHTENED_FLASH_START_TICKS,
|
|
||||||
events::GameEvent,
|
|
||||||
systems::common::components::EntityType,
|
|
||||||
systems::{AudioEvent, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Tracks the number of pellets consumed by the player for fruit spawning mechanics.
|
/// Tracks the number of pellets consumed by the player for fruit spawning mechanics.
|
||||||
#[derive(bevy_ecs::resource::Resource, Debug, Default)]
|
#[derive(bevy_ecs::resource::Resource, Debug, Default)]
|
||||||
@@ -73,85 +66,6 @@ impl FruitType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn item_system(
|
|
||||||
mut commands: Commands,
|
|
||||||
mut collision_events: EventReader<GameEvent>,
|
|
||||||
mut score: ResMut<ScoreResource>,
|
|
||||||
mut pellet_count: ResMut<PelletCount>,
|
|
||||||
pacman: Single<Entity, With<PacmanCollider>>,
|
|
||||||
item_query: Query<(Entity, &EntityType, &Position), With<ItemCollider>>,
|
|
||||||
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
|
|
||||||
mut events: EventWriter<AudioEvent>,
|
|
||||||
) {
|
|
||||||
for event in collision_events.read() {
|
|
||||||
if let GameEvent::Collision(entity1, entity2) = event {
|
|
||||||
// Check if one is Pacman and the other is an item
|
|
||||||
let (_, item_entity) = if *pacman == *entity1 && item_query.get(*entity2).is_ok() {
|
|
||||||
(*pacman, *entity2)
|
|
||||||
} else if *pacman == *entity2 && item_query.get(*entity1).is_ok() {
|
|
||||||
(*pacman, *entity1)
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the item type and update score
|
|
||||||
if let Ok((item_ent, entity_type, position)) = item_query.get(item_entity) {
|
|
||||||
if let Some(score_value) = entity_type.score_value() {
|
|
||||||
trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player");
|
|
||||||
score.0 += score_value;
|
|
||||||
|
|
||||||
// Remove the collected item
|
|
||||||
commands.entity(item_ent).despawn();
|
|
||||||
|
|
||||||
// Track pellet consumption for fruit spawning
|
|
||||||
if *entity_type == EntityType::Pellet {
|
|
||||||
pellet_count.0 += 1;
|
|
||||||
trace!(pellet_count = pellet_count.0, "Pellet consumed");
|
|
||||||
|
|
||||||
// Check if we should spawn a fruit
|
|
||||||
if pellet_count.0 == 5 || pellet_count.0 == 170 {
|
|
||||||
debug!(pellet_count = pellet_count.0, "Fruit spawn milestone reached");
|
|
||||||
commands.trigger(SpawnTrigger::Fruit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger bonus points effect if a fruit is collected
|
|
||||||
if matches!(*entity_type, EntityType::Fruit(_)) {
|
|
||||||
commands.trigger(SpawnTrigger::Bonus {
|
|
||||||
position: *position,
|
|
||||||
value: entity_type.score_value().unwrap(),
|
|
||||||
ttl: 60 * 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger audio if appropriate
|
|
||||||
if entity_type.is_collectible() {
|
|
||||||
events.write(AudioEvent::PlayEat);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make ghosts frightened when power pellet is collected
|
|
||||||
if matches!(*entity_type, EntityType::PowerPellet) {
|
|
||||||
// Convert seconds to frames (assumes 60 FPS)
|
|
||||||
let total_ticks = 60 * 5; // 5 seconds total
|
|
||||||
debug!(duration_ticks = total_ticks, "Power pellet collected, frightening ghosts");
|
|
||||||
|
|
||||||
// Set all ghosts to frightened state, except those in Eyes state
|
|
||||||
let mut frightened_count = 0;
|
|
||||||
for mut ghost_state in ghost_query.iter_mut() {
|
|
||||||
if !matches!(*ghost_state, GhostState::Eyes) {
|
|
||||||
*ghost_state = GhostState::new_frightened(total_ticks, FRIGHTENED_FLASH_START_TICKS);
|
|
||||||
frightened_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debug!(frightened_count, "Ghosts set to frightened state");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trigger to spawn a fruit
|
/// Trigger to spawn a fruit
|
||||||
#[derive(Event, Clone, Copy, Debug)]
|
#[derive(Event, Clone, Copy, Debug)]
|
||||||
pub enum SpawnTrigger {
|
pub enum SpawnTrigger {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ pub mod profiling;
|
|||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod render;
|
pub mod render;
|
||||||
|
|
||||||
pub mod animation;
|
mod animation;
|
||||||
pub mod blinking;
|
mod collision;
|
||||||
pub mod collision;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod ghost;
|
mod ghost;
|
||||||
|
mod hud;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod lifetime;
|
pub mod lifetime;
|
||||||
@@ -26,11 +26,11 @@ pub mod state;
|
|||||||
|
|
||||||
pub use self::animation::*;
|
pub use self::animation::*;
|
||||||
pub use self::audio::*;
|
pub use self::audio::*;
|
||||||
pub use self::blinking::*;
|
|
||||||
pub use self::collision::*;
|
pub use self::collision::*;
|
||||||
pub use self::common::*;
|
pub use self::common::*;
|
||||||
pub use self::debug::*;
|
pub use self::debug::*;
|
||||||
pub use self::ghost::*;
|
pub use self::ghost::*;
|
||||||
|
pub use self::hud::*;
|
||||||
pub use self::input::*;
|
pub use self::input::*;
|
||||||
pub use self::item::*;
|
pub use self::item::*;
|
||||||
pub use self::lifetime::*;
|
pub use self::lifetime::*;
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ pub fn player_control_system(
|
|||||||
) {
|
) {
|
||||||
// Handle events
|
// Handle events
|
||||||
for event in events.read() {
|
for event in events.read() {
|
||||||
if let GameEvent::Command(command) = event {
|
let GameEvent::Command(command) = event;
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
GameCommand::MovePlayer(direction) => {
|
GameCommand::MovePlayer(direction) => {
|
||||||
// Only handle movement if there's an unfrozen player
|
// Only handle movement if there's an unfrozen player
|
||||||
@@ -67,7 +68,6 @@ pub fn player_control_system(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes frame-by-frame movement for Pac-Man.
|
/// Executes frame-by-frame movement for Pac-Man.
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
|
use crate::error::{GameError, TextureError};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::map::direction::Direction;
|
|
||||||
use crate::systems::{
|
use crate::systems::{
|
||||||
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, GameStage, PlayerLife,
|
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, Position, SystemId,
|
||||||
PlayerLives, Position, ScoreResource, StartupSequence, SystemId, SystemTimings, TouchState, TtfAtlasResource,
|
SystemTimings, TtfAtlasResource,
|
||||||
};
|
};
|
||||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||||
use crate::texture::sprites::{GameSprite, PacmanSprite};
|
|
||||||
use crate::texture::text::TextTexture;
|
|
||||||
use crate::{
|
|
||||||
constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_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, Or, With, Without};
|
use bevy_ecs::query::{Changed, Or, With};
|
||||||
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::{Commands, NonSendMut, Query, Res, ResMut};
|
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
||||||
use glam::Vec2;
|
use glam::Vec2;
|
||||||
use sdl2::pixels::Color;
|
|
||||||
use sdl2::rect::{Point, Rect};
|
use sdl2::rect::{Point, Rect};
|
||||||
use sdl2::render::{BlendMode, Canvas, Texture};
|
use sdl2::render::{BlendMode, Canvas, Texture};
|
||||||
use sdl2::video::Window;
|
use sdl2::video::Window;
|
||||||
use std::cmp::Ordering;
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// A component for entities that have a sprite, with a layer for ordering.
|
/// A component for entities that have a sprite, with a layer for ordering.
|
||||||
@@ -41,6 +33,53 @@ pub struct RenderDirty(pub bool);
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct Hidden;
|
pub struct Hidden;
|
||||||
|
|
||||||
|
/// A component that controls entity visibility in the render system.
|
||||||
|
///
|
||||||
|
/// Entities without this component are considered visible by default.
|
||||||
|
/// This allows for efficient rendering where only entities that need
|
||||||
|
/// visibility control have this component.
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Visibility(pub bool);
|
||||||
|
|
||||||
|
impl Default for Visibility {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(true) // Default to visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visibility {
|
||||||
|
/// Creates a visible Visibility component
|
||||||
|
pub fn visible() -> Self {
|
||||||
|
Self(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a hidden Visibility component
|
||||||
|
pub fn hidden() -> Self {
|
||||||
|
Self(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the entity is visible
|
||||||
|
pub fn is_visible(&self) -> bool {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the entity is hidden
|
||||||
|
#[allow(dead_code)] // Used in tests
|
||||||
|
pub fn is_hidden(&self) -> bool {
|
||||||
|
!self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the entity visible
|
||||||
|
pub fn show(&mut self) {
|
||||||
|
self.0 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the visibility state
|
||||||
|
pub fn toggle(&mut self) {
|
||||||
|
self.0 = !self.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Enum to identify which texture is being rendered to in the combined render system
|
/// Enum to identify which texture is being rendered to in the combined render system
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
enum RenderTarget {
|
enum RenderTarget {
|
||||||
@@ -51,257 +90,26 @@ enum RenderTarget {
|
|||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn dirty_render_system(
|
pub fn dirty_render_system(
|
||||||
mut dirty: ResMut<RenderDirty>,
|
mut dirty: ResMut<RenderDirty>,
|
||||||
changed: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>,
|
changed: Query<(), Or<(Changed<Renderable>, Changed<Position>, Changed<Visibility>)>>,
|
||||||
removed_hidden: RemovedComponents<Hidden>,
|
|
||||||
removed_renderables: RemovedComponents<Renderable>,
|
removed_renderables: RemovedComponents<Renderable>,
|
||||||
) {
|
) {
|
||||||
let changed_count = changed.iter().count();
|
if changed.iter().count() > 0 || !removed_renderables.is_empty() {
|
||||||
let removed_hidden_count = removed_hidden.len();
|
|
||||||
let removed_renderables_count = removed_renderables.len();
|
|
||||||
|
|
||||||
if changed_count > 0 || removed_hidden_count > 0 || removed_renderables_count > 0 {
|
|
||||||
dirty.0 = true;
|
dirty.0 = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System that manages player life sprite entities.
|
|
||||||
/// Spawns and despawns life sprite entities based on changes to PlayerLives resource.
|
|
||||||
/// Each life sprite is positioned based on its index (0, 1, 2, etc. from left to right).
|
|
||||||
pub fn player_life_sprite_system(
|
|
||||||
mut commands: Commands,
|
|
||||||
atlas: NonSendMut<SpriteAtlas>,
|
|
||||||
current_life_sprites: Query<(Entity, &PlayerLife)>,
|
|
||||||
player_lives: Res<PlayerLives>,
|
|
||||||
mut errors: EventWriter<GameError>,
|
|
||||||
) {
|
|
||||||
let displayed_lives = player_lives.0.saturating_sub(1);
|
|
||||||
|
|
||||||
// Get current life sprite entities, sorted by index
|
|
||||||
let mut current_sprites: Vec<_> = current_life_sprites.iter().collect();
|
|
||||||
current_sprites.sort_by_key(|(_, life)| life.index);
|
|
||||||
let current_count = current_sprites.len() as u8;
|
|
||||||
|
|
||||||
// Calculate the difference
|
|
||||||
let diff = (displayed_lives as i8) - (current_count as i8);
|
|
||||||
|
|
||||||
match diff.cmp(&0) {
|
|
||||||
// Ignore when the number of lives displayed is correct
|
|
||||||
Ordering::Equal => {}
|
|
||||||
// Spawn new life sprites
|
|
||||||
Ordering::Greater => {
|
|
||||||
let life_sprite = match atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path()) {
|
|
||||||
Ok(sprite) => sprite,
|
|
||||||
Err(e) => {
|
|
||||||
errors.write(e.into());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for i in 0..diff {
|
|
||||||
let position = calculate_life_sprite_position(i as u32);
|
|
||||||
|
|
||||||
commands.spawn((
|
|
||||||
PlayerLife { index: i as u32 },
|
|
||||||
Renderable {
|
|
||||||
sprite: life_sprite,
|
|
||||||
layer: 255, // High layer to render on top
|
|
||||||
},
|
|
||||||
PixelPosition {
|
|
||||||
pixel_position: position,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove excess life sprites (highest indices first)
|
|
||||||
Ordering::Less => {
|
|
||||||
let to_remove = diff.unsigned_abs();
|
|
||||||
let sprites_to_remove: Vec<_> = current_sprites
|
|
||||||
.iter()
|
|
||||||
.rev() // Start from highest index
|
|
||||||
.take(to_remove as usize)
|
|
||||||
.map(|(entity, _)| *entity)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for entity in sprites_to_remove {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Component for Renderables to store an exact pixel position
|
/// Component for Renderables to store an exact pixel position
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct PixelPosition {
|
pub struct PixelPosition {
|
||||||
pub pixel_position: Vec2,
|
pub pixel_position: Vec2,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the pixel position for a life sprite based on its index
|
|
||||||
fn calculate_life_sprite_position(index: u32) -> Vec2 {
|
|
||||||
let start_x = CELL_SIZE * 2; // 2 cells from left
|
|
||||||
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
|
|
||||||
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
|
|
||||||
|
|
||||||
let x = start_x + ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
|
|
||||||
let y = start_y - CELL_SIZE / 2;
|
|
||||||
|
|
||||||
Vec2::new((x + CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
||||||
pub struct MapTextureResource(pub Texture);
|
pub struct MapTextureResource(pub Texture);
|
||||||
|
|
||||||
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
||||||
pub struct BackbufferResource(pub Texture);
|
pub struct BackbufferResource(pub Texture);
|
||||||
|
|
||||||
/// Renders touch UI overlay for mobile/testing.
|
|
||||||
pub fn touch_ui_render_system(
|
|
||||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
|
||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
|
||||||
touch_state: Res<TouchState>,
|
|
||||||
mut errors: EventWriter<GameError>,
|
|
||||||
) {
|
|
||||||
if let Some(ref touch_data) = touch_state.active_touch {
|
|
||||||
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
|
||||||
// Set blend mode for transparency
|
|
||||||
canvas.set_blend_mode(BlendMode::Blend);
|
|
||||||
|
|
||||||
// Draw semi-transparent circle at touch start position
|
|
||||||
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
|
|
||||||
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
|
|
||||||
|
|
||||||
// Draw a simple circle by drawing filled rectangles (basic approach)
|
|
||||||
let radius = 30;
|
|
||||||
for dy in -radius..=radius {
|
|
||||||
for dx in -radius..=radius {
|
|
||||||
if dx * dx + dy * dy <= radius * radius {
|
|
||||||
let point = Point::new(center.x + dx, center.y + dy);
|
|
||||||
if let Err(e) = canvas.draw_point(point) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw direction indicator if we have a direction
|
|
||||||
if let Some(direction) = touch_data.current_direction {
|
|
||||||
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
|
|
||||||
|
|
||||||
// Draw arrow indicating direction
|
|
||||||
let arrow_length = 40;
|
|
||||||
let (dx, dy) = match direction {
|
|
||||||
crate::map::direction::Direction::Up => (0, -arrow_length),
|
|
||||||
crate::map::direction::Direction::Down => (0, arrow_length),
|
|
||||||
crate::map::direction::Direction::Left => (-arrow_length, 0),
|
|
||||||
crate::map::direction::Direction::Right => (arrow_length, 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
let end_point = Point::new(center.x + dx, center.y + dy);
|
|
||||||
if let Err(e) = canvas.draw_line(center, end_point) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw arrowhead (simple approach)
|
|
||||||
let arrow_size = 8;
|
|
||||||
match direction {
|
|
||||||
crate::map::direction::Direction::Up => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
|
||||||
}
|
|
||||||
crate::map::direction::Direction::Down => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
|
||||||
}
|
|
||||||
crate::map::direction::Direction::Left => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
|
||||||
}
|
|
||||||
crate::map::direction::Direction::Right => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders the HUD (score, lives, etc.) on top of the game.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn hud_render_system(
|
|
||||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
|
||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
|
||||||
mut atlas: NonSendMut<SpriteAtlas>,
|
|
||||||
score: Res<ScoreResource>,
|
|
||||||
stage: Res<GameStage>,
|
|
||||||
mut errors: EventWriter<GameError>,
|
|
||||||
) {
|
|
||||||
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
|
||||||
let mut text_renderer = TextTexture::new(1.0);
|
|
||||||
|
|
||||||
// Render lives and high score text in white
|
|
||||||
let lives_text = "1UP HIGH SCORE ";
|
|
||||||
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
|
|
||||||
|
|
||||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render score text
|
|
||||||
let score_text = format!("{:02}", score.0);
|
|
||||||
let score_offset = 7 - (score_text.len() as i32);
|
|
||||||
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
|
||||||
|
|
||||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render high score text
|
|
||||||
let high_score_text = format!("{:02}", score.0);
|
|
||||||
let high_score_offset = 17 - (high_score_text.len() as i32);
|
|
||||||
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
|
||||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
|
|
||||||
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
|
|
||||||
if matches!(
|
|
||||||
*stage,
|
|
||||||
GameStage::Starting(StartupSequence::TextOnly { .. })
|
|
||||||
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
|
|
||||||
) {
|
|
||||||
let ready_text = "READY!";
|
|
||||||
let ready_width = text_renderer.text_width(ready_text);
|
|
||||||
let ready_position = glam::UVec2::new((CANVAS_SIZE.x - ready_width) / 2, 160);
|
|
||||||
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
|
|
||||||
let player_one_text = "PLAYER ONE";
|
|
||||||
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);
|
|
||||||
|
|
||||||
if let Err(e) =
|
|
||||||
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
|
|
||||||
{
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn render_system(
|
pub fn render_system(
|
||||||
@@ -311,8 +119,14 @@ pub fn render_system(
|
|||||||
map: &Res<Map>,
|
map: &Res<Map>,
|
||||||
dirty: &Res<RenderDirty>,
|
dirty: &Res<RenderDirty>,
|
||||||
renderables: &Query<
|
renderables: &Query<
|
||||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
(
|
||||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
Entity,
|
||||||
|
&Renderable,
|
||||||
|
Option<&Position>,
|
||||||
|
Option<&PixelPosition>,
|
||||||
|
Option<&Visibility>,
|
||||||
|
),
|
||||||
|
Or<(With<Position>, With<PixelPosition>)>,
|
||||||
>,
|
>,
|
||||||
errors: &mut EventWriter<GameError>,
|
errors: &mut EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
@@ -329,14 +143,17 @@ pub fn render_system(
|
|||||||
errors.write(TextureError::RenderFailed(e.to_string()).into());
|
errors.write(TextureError::RenderFailed(e.to_string()).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render all entities to the backbuffer
|
// Collect and filter visible entities, then sort by layer
|
||||||
for (_entity, renderable, position, pixel_position) in renderables
|
let mut visible_entities: Vec<_> = renderables
|
||||||
.iter()
|
.iter()
|
||||||
.sort_by_key::<(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>), _>(|(_, renderable, _, _)| {
|
.filter(|(_, _, _, _, visibility)| visibility.copied().unwrap_or_default().is_visible())
|
||||||
renderable.layer
|
.collect();
|
||||||
})
|
|
||||||
.rev()
|
visible_entities.sort_by_key(|(_, renderable, _, _, _)| renderable.layer);
|
||||||
{
|
visible_entities.reverse();
|
||||||
|
|
||||||
|
// Render all visible entities to the backbuffer
|
||||||
|
for (_entity, renderable, position, pixel_position, _visibility) in visible_entities {
|
||||||
let pos = if let Some(position) = position {
|
let pos = if let Some(position) = position {
|
||||||
position.get_pixel_position(&map.graph)
|
position.get_pixel_position(&map.graph)
|
||||||
} else {
|
} else {
|
||||||
@@ -384,8 +201,14 @@ pub fn combined_render_system(
|
|||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
dirty: Res<RenderDirty>,
|
dirty: Res<RenderDirty>,
|
||||||
renderables: Query<
|
renderables: Query<
|
||||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
(
|
||||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
Entity,
|
||||||
|
&Renderable,
|
||||||
|
Option<&Position>,
|
||||||
|
Option<&PixelPosition>,
|
||||||
|
Option<&Visibility>,
|
||||||
|
),
|
||||||
|
Or<(With<Position>, With<PixelPosition>)>,
|
||||||
>,
|
>,
|
||||||
colliders: Query<(&Collider, &Position)>,
|
colliders: Query<(&Collider, &Position)>,
|
||||||
cursor: Res<CursorPosition>,
|
cursor: Res<CursorPosition>,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
use std::mem::discriminant;
|
use std::mem::discriminant;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::constants;
|
||||||
use crate::events::StageTransition;
|
use crate::events::StageTransition;
|
||||||
use crate::systems::SpawnTrigger;
|
use crate::map::direction::Direction;
|
||||||
|
use crate::systems::{EntityType, ItemCollider, SpawnTrigger, Velocity};
|
||||||
use crate::{
|
use crate::{
|
||||||
map::builder::Map,
|
map::builder::Map,
|
||||||
systems::{
|
systems::{
|
||||||
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
|
AudioEvent, Blinking, DirectionalAnimation, Dying, Frozen, Ghost, GhostCollider, GhostState, LinearAnimation, Looping,
|
||||||
LinearAnimation, Looping, NodeId, PlayerControlled, Position,
|
NodeId, PlayerControlled, Position, Visibility,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
@@ -34,16 +36,54 @@ pub enum GameStage {
|
|||||||
GhostEatenPause {
|
GhostEatenPause {
|
||||||
remaining_ticks: u32,
|
remaining_ticks: u32,
|
||||||
ghost_entity: Entity,
|
ghost_entity: Entity,
|
||||||
|
ghost_type: Ghost,
|
||||||
node: NodeId,
|
node: NodeId,
|
||||||
},
|
},
|
||||||
/// The player has died and the death sequence is in progress.
|
/// The player has died and the death sequence is in progress. At the end, the player will return to the startup sequence or game over.
|
||||||
PlayerDying(DyingSequence),
|
PlayerDying(DyingSequence),
|
||||||
/// The level is restarting after a death.
|
|
||||||
LevelRestarting,
|
|
||||||
/// The game has ended.
|
/// The game has ended.
|
||||||
GameOver,
|
GameOver,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait TooSimilar {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TooSimilar for GameStage {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool {
|
||||||
|
discriminant(self) == discriminant(other) && {
|
||||||
|
// These states are very simple, so they're 'too similar' automatically
|
||||||
|
if matches!(self, GameStage::Playing | GameStage::GameOver) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since the discriminant is the same but the values are different, it's the interior value that is somehow different
|
||||||
|
match (self, other) {
|
||||||
|
// These states are similar if their interior values are similar as well
|
||||||
|
(GameStage::Starting(startup), GameStage::Starting(other)) => startup.too_similar(other),
|
||||||
|
(GameStage::PlayerDying(dying), GameStage::PlayerDying(other)) => dying.too_similar(other),
|
||||||
|
(
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
|
node,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
ghost_entity: other_ghost_entity,
|
||||||
|
ghost_type: other_ghost_type,
|
||||||
|
node: other_node,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => ghost_entity == other_ghost_entity && ghost_type == other_ghost_type && node == other_node,
|
||||||
|
// Already handled, but kept to properly exhaust the match
|
||||||
|
(GameStage::Playing, _) | (GameStage::GameOver, _) => unreachable!(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A resource that manages the multi-stage startup sequence of the game.
|
/// A resource that manages the multi-stage startup sequence of the game.
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
pub enum StartupSequence {
|
pub enum StartupSequence {
|
||||||
@@ -70,6 +110,12 @@ impl Default for GameStage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TooSimilar for StartupSequence {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool {
|
||||||
|
discriminant(self) == discriminant(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The state machine for the multi-stage death sequence.
|
/// The state machine for the multi-stage death sequence.
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
pub enum DyingSequence {
|
pub enum DyingSequence {
|
||||||
@@ -81,6 +127,12 @@ pub enum DyingSequence {
|
|||||||
Hidden { remaining_ticks: u32 },
|
Hidden { remaining_ticks: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TooSimilar for DyingSequence {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool {
|
||||||
|
discriminant(self) == discriminant(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A resource to store the number of player lives.
|
/// A resource to store the number of player lives.
|
||||||
#[derive(Resource, Debug)]
|
#[derive(Resource, Debug)]
|
||||||
pub struct PlayerLives(pub u8);
|
pub struct PlayerLives(pub u8);
|
||||||
@@ -105,20 +157,25 @@ pub fn stage_system(
|
|||||||
mut stage_event_reader: EventReader<StageTransition>,
|
mut stage_event_reader: EventReader<StageTransition>,
|
||||||
mut blinking_query: Query<Entity, With<Blinking>>,
|
mut blinking_query: Query<Entity, With<Blinking>>,
|
||||||
player: Single<(Entity, &mut Position), With<PlayerControlled>>,
|
player: Single<(Entity, &mut Position), With<PlayerControlled>>,
|
||||||
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
|
mut item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
|
||||||
|
mut ghost_query: Query<(Entity, &Ghost, &mut Position, &mut GhostState), (With<GhostCollider>, Without<PlayerControlled>)>,
|
||||||
) {
|
) {
|
||||||
let old_state = *game_state;
|
let old_state = *game_state;
|
||||||
let mut new_state: Option<GameStage> = None;
|
let mut new_state: Option<GameStage> = None;
|
||||||
|
|
||||||
// Handle stage transition requests before normal ticking
|
// Handle stage transition requests before normal ticking
|
||||||
for event in stage_event_reader.read() {
|
for event in stage_event_reader.read() {
|
||||||
let StageTransition::GhostEatenPause { ghost_entity } = *event;
|
let StageTransition::GhostEatenPause {
|
||||||
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
|
} = *event;
|
||||||
let pac_node = player.1.current_node();
|
let pac_node = player.1.current_node();
|
||||||
|
|
||||||
debug!(ghost_entity = ?ghost_entity, node = pac_node, "Ghost eaten, entering pause state");
|
debug!(ghost = ?ghost_type, node = pac_node, "Ghost eaten, entering pause state");
|
||||||
new_state = Some(GameStage::GhostEatenPause {
|
new_state = Some(GameStage::GhostEatenPause {
|
||||||
remaining_ticks: 30,
|
remaining_ticks: 30,
|
||||||
ghost_entity,
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
node: pac_node,
|
node: pac_node,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -131,7 +188,6 @@ pub fn stage_system(
|
|||||||
remaining_ticks: remaining_ticks - 1,
|
remaining_ticks: remaining_ticks - 1,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
debug!("Transitioning from text-only to characters visible startup stage");
|
|
||||||
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,12 +206,14 @@ pub fn stage_system(
|
|||||||
GameStage::GhostEatenPause {
|
GameStage::GhostEatenPause {
|
||||||
remaining_ticks,
|
remaining_ticks,
|
||||||
ghost_entity,
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
node,
|
node,
|
||||||
} => {
|
} => {
|
||||||
if remaining_ticks > 0 {
|
if remaining_ticks > 0 {
|
||||||
GameStage::GhostEatenPause {
|
GameStage::GhostEatenPause {
|
||||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||||
ghost_entity,
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
node,
|
node,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -194,8 +252,8 @@ pub fn stage_system(
|
|||||||
player_lives.0 = player_lives.0.saturating_sub(1);
|
player_lives.0 = player_lives.0.saturating_sub(1);
|
||||||
|
|
||||||
if player_lives.0 > 0 {
|
if player_lives.0 > 0 {
|
||||||
info!(remaining_lives = player_lives.0, "Player died, restarting level");
|
info!(remaining_lives = player_lives.0, "Player died, returning to startup sequence");
|
||||||
GameStage::LevelRestarting
|
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||||
} else {
|
} else {
|
||||||
warn!("All lives lost, game over");
|
warn!("All lives lost, game over");
|
||||||
GameStage::GameOver
|
GameStage::GameOver
|
||||||
@@ -203,10 +261,6 @@ pub fn stage_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
GameStage::LevelRestarting => {
|
|
||||||
debug!("Level restart complete, returning to startup sequence");
|
|
||||||
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
|
||||||
}
|
|
||||||
GameStage::GameOver => GameStage::GameOver,
|
GameStage::GameOver => GameStage::GameOver,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,17 +268,26 @@ pub fn stage_system(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !old_state.too_similar(&new_state) {
|
||||||
|
debug!(old_state = ?old_state, new_state = ?new_state, "Game stage transition");
|
||||||
|
}
|
||||||
|
|
||||||
match (old_state, new_state) {
|
match (old_state, new_state) {
|
||||||
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
|
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
|
||||||
// Freeze the player & ghosts
|
// Freeze the player & non-eaten ghosts
|
||||||
commands.entity(player.0).insert(Frozen);
|
commands.entity(player.0).insert(Frozen);
|
||||||
for (entity, _, _) in ghost_query.iter_mut() {
|
commands.entity(ghost_entity).insert(Frozen);
|
||||||
|
for (entity, _, _, state) in ghost_query.iter_mut() {
|
||||||
|
// Only freeze ghosts that are not currently eaten
|
||||||
|
if *state != GhostState::Eyes {
|
||||||
|
debug!(ghost = ?entity, "Freezing ghost");
|
||||||
commands.entity(entity).insert(Frozen);
|
commands.entity(entity).insert(Frozen);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hide the player & eaten ghost
|
// Hide the player & eaten ghost
|
||||||
commands.entity(player.0).insert(Hidden);
|
commands.entity(player.0).insert(Visibility::hidden());
|
||||||
commands.entity(ghost_entity).insert(Hidden);
|
commands.entity(ghost_entity).insert(Visibility::hidden());
|
||||||
|
|
||||||
// Spawn bonus points entity at Pac-Man's position
|
// Spawn bonus points entity at Pac-Man's position
|
||||||
commands.trigger(SpawnTrigger::Bonus {
|
commands.trigger(SpawnTrigger::Bonus {
|
||||||
@@ -236,102 +299,111 @@ pub fn stage_system(
|
|||||||
}
|
}
|
||||||
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
|
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
|
||||||
// Unfreeze and reveal the player & all ghosts
|
// Unfreeze and reveal the player & all ghosts
|
||||||
commands.entity(player.0).remove::<(Frozen, Hidden)>();
|
commands.entity(player.0).remove::<Frozen>().insert(Visibility::visible());
|
||||||
for (entity, _, _) in ghost_query.iter_mut() {
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
commands.entity(entity).remove::<(Frozen, Hidden)>();
|
commands.entity(entity).remove::<Frozen>().insert(Visibility::visible());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reveal the eaten ghost and switch it to Eyes state
|
// Reveal the eaten ghost and switch it to Eyes state
|
||||||
commands.entity(ghost_entity).insert(GhostState::Eyes);
|
commands.entity(ghost_entity).insert(GhostState::Eyes);
|
||||||
}
|
}
|
||||||
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
|
(_, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
|
||||||
// Freeze the player & ghosts
|
// Freeze the player & ghosts
|
||||||
commands.entity(player.0).insert(Frozen);
|
commands.entity(player.0).insert(Frozen);
|
||||||
for (entity, _, _) in ghost_query.iter_mut() {
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
commands.entity(entity).insert(Frozen);
|
commands.entity(entity).insert(Frozen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
|
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
|
||||||
// Hide the ghosts
|
// Hide the ghosts
|
||||||
for (entity, _, _) in ghost_query.iter_mut() {
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
commands.entity(entity).insert(Hidden);
|
commands.entity(entity).insert(Visibility::hidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start Pac-Man's death animation
|
// Start Pac-Man's death animation
|
||||||
commands.entity(player.0).insert((Dying, player_death_animation.0.clone()));
|
commands
|
||||||
|
.entity(player.0)
|
||||||
|
.remove::<DirectionalAnimation>()
|
||||||
|
.insert((Dying, player_death_animation.0.clone()));
|
||||||
|
|
||||||
// Play the death sound
|
// Play the death sound
|
||||||
audio_events.write(AudioEvent::PlayDeath);
|
audio_events.write(AudioEvent::PlayDeath);
|
||||||
}
|
}
|
||||||
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
|
(_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
|
||||||
// Hide the player
|
// Pac-Man's death animation is complete, so he should be hidden just like the ghosts.
|
||||||
commands.entity(player.0).insert(Hidden);
|
// Then, we reset them all back to their original positions and states.
|
||||||
}
|
|
||||||
(_, GameStage::LevelRestarting) => {
|
|
||||||
let (player_entity, mut pos) = player.into_inner();
|
|
||||||
*pos = Position::Stopped {
|
|
||||||
node: map.start_positions.pacman,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Freeze the blinking, force them to be visible (if they were hidden by blinking)
|
// Freeze the blinking power pellets, force them to be visible (if they were hidden by blinking)
|
||||||
for entity in blinking_query.iter_mut() {
|
for entity in blinking_query.iter_mut() {
|
||||||
commands.entity(entity).insert(Frozen).remove::<Hidden>();
|
commands.entity(entity).insert(Frozen).insert(Visibility::visible());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete any fruit entities
|
||||||
|
for (entity, _) in item_query
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|(_, entity_type)| matches!(entity_type, EntityType::Fruit(_)))
|
||||||
|
{
|
||||||
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the player animation
|
// Reset the player animation
|
||||||
commands
|
commands
|
||||||
.entity(player_entity)
|
.entity(player.0)
|
||||||
.remove::<(Frozen, Dying, LinearAnimation, Looping)>()
|
.remove::<(Dying, LinearAnimation, Looping)>()
|
||||||
.insert(player_animation.0.clone());
|
.insert((
|
||||||
|
Velocity {
|
||||||
|
speed: constants::mechanics::PLAYER_SPEED,
|
||||||
|
direction: Direction::Left,
|
||||||
|
},
|
||||||
|
Position::Stopped {
|
||||||
|
node: map.start_positions.pacman,
|
||||||
|
},
|
||||||
|
player_animation.0.clone(),
|
||||||
|
Visibility::hidden(),
|
||||||
|
Frozen,
|
||||||
|
));
|
||||||
|
|
||||||
// Reset ghost positions and state
|
// Reset ghost positions and state
|
||||||
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() {
|
for (ghost_entity, ghost, _, _) in ghost_query.iter_mut() {
|
||||||
*ghost_pos = Position::Stopped {
|
commands.entity(ghost_entity).insert((
|
||||||
|
GhostState::Normal,
|
||||||
|
Position::Stopped {
|
||||||
node: match ghost {
|
node: match ghost {
|
||||||
Ghost::Blinky => map.start_positions.blinky,
|
Ghost::Blinky => map.start_positions.blinky,
|
||||||
Ghost::Pinky => map.start_positions.pinky,
|
Ghost::Pinky => map.start_positions.pinky,
|
||||||
Ghost::Inky => map.start_positions.inky,
|
Ghost::Inky => map.start_positions.inky,
|
||||||
Ghost::Clyde => map.start_positions.clyde,
|
Ghost::Clyde => map.start_positions.clyde,
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
commands
|
Frozen,
|
||||||
.entity(ghost_entity)
|
Visibility::hidden(),
|
||||||
.remove::<(Frozen, Hidden, Eaten)>()
|
));
|
||||||
.insert(GhostState::Normal);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
|
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
|
||||||
// Unhide the player & ghosts
|
// Unhide the player & ghosts
|
||||||
commands.entity(player.0).remove::<Hidden>();
|
commands.entity(player.0).insert(Visibility::visible());
|
||||||
for (entity, _, _) in ghost_query.iter_mut() {
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
commands.entity(entity).remove::<Hidden>();
|
commands.entity(entity).insert(Visibility::visible());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
|
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
|
||||||
// Unfreeze the player & ghosts & blinking
|
// Unfreeze the player & ghosts & blinking
|
||||||
commands.entity(player.0).remove::<Frozen>();
|
commands.entity(player.0).remove::<Frozen>();
|
||||||
for (entity, _, _) in ghost_query.iter_mut() {
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
commands.entity(entity).remove::<Frozen>();
|
commands.entity(entity).remove::<Frozen>();
|
||||||
}
|
}
|
||||||
for entity in blinking_query.iter_mut() {
|
for entity in blinking_query.iter_mut() {
|
||||||
commands.entity(entity).remove::<Frozen>();
|
commands.entity(entity).remove::<Frozen>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(GameStage::PlayerDying(..), GameStage::GameOver) => {
|
(_, GameStage::GameOver) => {
|
||||||
// Freeze blinking
|
// Freeze blinking
|
||||||
for entity in blinking_query.iter_mut() {
|
for entity in blinking_query.iter_mut() {
|
||||||
commands.entity(entity).insert(Frozen);
|
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;
|
*game_state = new_state;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ pub struct TtfAtlas {
|
|||||||
char_tiles: HashMap<char, TtfCharTile>,
|
char_tiles: HashMap<char, TtfCharTile>,
|
||||||
/// Cached color modulation state to avoid redundant SDL2 calls
|
/// Cached color modulation state to avoid redundant SDL2 calls
|
||||||
last_modulation: Option<Color>,
|
last_modulation: Option<Color>,
|
||||||
|
/// Cached maximum character height
|
||||||
|
max_char_height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTF_CHARS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:-/()ms μµ%± ";
|
const TTF_CHARS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:-/()ms μµ%± ";
|
||||||
@@ -101,6 +103,7 @@ impl TtfAtlas {
|
|||||||
texture: atlas_texture,
|
texture: atlas_texture,
|
||||||
char_tiles,
|
char_tiles,
|
||||||
last_modulation: None,
|
last_modulation: None,
|
||||||
|
max_char_height: max_height,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,12 +264,6 @@ impl TtfRenderer {
|
|||||||
/// Calculate the height of text in pixels
|
/// Calculate the height of text in pixels
|
||||||
pub fn text_height(&self, atlas: &TtfAtlas) -> u32 {
|
pub fn text_height(&self, atlas: &TtfAtlas) -> u32 {
|
||||||
// Find the maximum height among all characters
|
// Find the maximum height among all characters
|
||||||
atlas
|
(atlas.max_char_height as f32 * self.scale) as u32
|
||||||
.char_tiles
|
|
||||||
.values()
|
|
||||||
.map(|tile| tile.size.y)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0)
|
|
||||||
.saturating_mul(self.scale as u32)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use bevy_ecs::{entity::Entity, system::RunSystemOnce, world::World};
|
use bevy_ecs::{entity::Entity, system::RunSystemOnce, world::World};
|
||||||
use pacman::systems::{blinking_system, Blinking, DeltaTime, Frozen, Hidden, Renderable};
|
use pacman::systems::{blinking_system, Blinking, DeltaTime, Frozen, Renderable, Visibility};
|
||||||
use speculoos::prelude::*;
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
@@ -20,11 +20,12 @@ fn spawn_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
|
|||||||
sprite: common::mock_atlas_tile(1),
|
sprite: common::mock_atlas_tile(1),
|
||||||
layer: 0,
|
layer: 0,
|
||||||
},
|
},
|
||||||
|
Visibility::visible(),
|
||||||
))
|
))
|
||||||
.id()
|
.id()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a test entity with blinking, renderable, and hidden components
|
/// Spawns a test entity with blinking, renderable, and hidden visibility
|
||||||
fn spawn_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
|
fn spawn_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
|
||||||
world
|
world
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -33,7 +34,7 @@ fn spawn_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entit
|
|||||||
sprite: common::mock_atlas_tile(1),
|
sprite: common::mock_atlas_tile(1),
|
||||||
layer: 0,
|
layer: 0,
|
||||||
},
|
},
|
||||||
Hidden,
|
Visibility::hidden(),
|
||||||
))
|
))
|
||||||
.id()
|
.id()
|
||||||
}
|
}
|
||||||
@@ -47,12 +48,13 @@ fn spawn_frozen_blinking_entity(world: &mut World, interval_ticks: u32) -> Entit
|
|||||||
sprite: common::mock_atlas_tile(1),
|
sprite: common::mock_atlas_tile(1),
|
||||||
layer: 0,
|
layer: 0,
|
||||||
},
|
},
|
||||||
|
Visibility::visible(),
|
||||||
Frozen,
|
Frozen,
|
||||||
))
|
))
|
||||||
.id()
|
.id()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a test entity with blinking, renderable, hidden, and frozen components
|
/// Spawns a test entity with blinking, renderable, hidden visibility, and frozen components
|
||||||
fn spawn_frozen_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
|
fn spawn_frozen_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
|
||||||
world
|
world
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -61,7 +63,7 @@ fn spawn_frozen_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -
|
|||||||
sprite: common::mock_atlas_tile(1),
|
sprite: common::mock_atlas_tile(1),
|
||||||
layer: 0,
|
layer: 0,
|
||||||
},
|
},
|
||||||
Hidden,
|
Visibility::hidden(),
|
||||||
Frozen,
|
Frozen,
|
||||||
))
|
))
|
||||||
.id()
|
.id()
|
||||||
@@ -73,9 +75,22 @@ fn run_blinking_system(world: &mut World, delta_ticks: u32) {
|
|||||||
world.run_system_once(blinking_system).unwrap();
|
world.run_system_once(blinking_system).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if an entity has the Hidden component
|
/// Checks if an entity is visible
|
||||||
fn has_hidden_component(world: &World, entity: Entity) -> bool {
|
fn is_entity_visible(world: &World, entity: Entity) -> bool {
|
||||||
world.entity(entity).contains::<Hidden>()
|
world
|
||||||
|
.entity(entity)
|
||||||
|
.get::<Visibility>()
|
||||||
|
.map(|v| v.is_visible())
|
||||||
|
.unwrap_or(true) // Default to visible if no Visibility component
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if an entity is hidden
|
||||||
|
fn is_entity_hidden(world: &World, entity: Entity) -> bool {
|
||||||
|
world
|
||||||
|
.entity(entity)
|
||||||
|
.get::<Visibility>()
|
||||||
|
.map(|v| v.is_hidden())
|
||||||
|
.unwrap_or(false) // Default to visible if no Visibility component
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if an entity has the Frozen component
|
/// Checks if an entity has the Frozen component
|
||||||
@@ -100,7 +115,7 @@ fn test_blinking_system_normal_interval_no_toggle() {
|
|||||||
run_blinking_system(&mut world, 3);
|
run_blinking_system(&mut world, 3);
|
||||||
|
|
||||||
// Entity should not be hidden yet
|
// Entity should not be hidden yet
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
// Check that timer was updated
|
// Check that timer was updated
|
||||||
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
||||||
@@ -116,7 +131,7 @@ fn test_blinking_system_normal_interval_first_toggle() {
|
|||||||
run_blinking_system(&mut world, 5);
|
run_blinking_system(&mut world, 5);
|
||||||
|
|
||||||
// Entity should now be hidden
|
// Entity should now be hidden
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_true();
|
assert_that(&is_entity_hidden(&world, entity)).is_true();
|
||||||
|
|
||||||
// Check that timer was reset
|
// Check that timer was reset
|
||||||
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
||||||
@@ -130,11 +145,11 @@ fn test_blinking_system_normal_interval_second_toggle() {
|
|||||||
|
|
||||||
// First toggle: 5 ticks
|
// First toggle: 5 ticks
|
||||||
run_blinking_system(&mut world, 5);
|
run_blinking_system(&mut world, 5);
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_true();
|
assert_that(&is_entity_hidden(&world, entity)).is_true();
|
||||||
|
|
||||||
// Second toggle: another 5 ticks
|
// Second toggle: another 5 ticks
|
||||||
run_blinking_system(&mut world, 5);
|
run_blinking_system(&mut world, 5);
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -146,7 +161,7 @@ fn test_blinking_system_normal_interval_multiple_intervals() {
|
|||||||
run_blinking_system(&mut world, 7);
|
run_blinking_system(&mut world, 7);
|
||||||
|
|
||||||
// Should toggle twice (even number), so back to original state (not hidden)
|
// Should toggle twice (even number), so back to original state (not hidden)
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
// Check that timer was updated to remainder
|
// Check that timer was updated to remainder
|
||||||
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
||||||
@@ -162,7 +177,7 @@ fn test_blinking_system_normal_interval_odd_intervals() {
|
|||||||
run_blinking_system(&mut world, 5);
|
run_blinking_system(&mut world, 5);
|
||||||
|
|
||||||
// Should toggle twice (even number), so back to original state (not hidden)
|
// Should toggle twice (even number), so back to original state (not hidden)
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
// Check that timer was updated to remainder
|
// Check that timer was updated to remainder
|
||||||
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
||||||
@@ -178,7 +193,7 @@ fn test_blinking_system_zero_interval_with_ticks() {
|
|||||||
run_blinking_system(&mut world, 1);
|
run_blinking_system(&mut world, 1);
|
||||||
|
|
||||||
// Entity should be hidden immediately
|
// Entity should be hidden immediately
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_true();
|
assert_that(&is_entity_hidden(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -190,7 +205,7 @@ fn test_blinking_system_zero_interval_no_ticks() {
|
|||||||
run_blinking_system(&mut world, 0);
|
run_blinking_system(&mut world, 0);
|
||||||
|
|
||||||
// Entity should not be hidden (no time passed)
|
// Entity should not be hidden (no time passed)
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -202,7 +217,7 @@ fn test_blinking_system_zero_interval_toggle_back() {
|
|||||||
run_blinking_system(&mut world, 1);
|
run_blinking_system(&mut world, 1);
|
||||||
|
|
||||||
// Entity should be unhidden
|
// Entity should be unhidden
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -214,7 +229,7 @@ fn test_blinking_system_frozen_entity_unhidden() {
|
|||||||
run_blinking_system(&mut world, 10);
|
run_blinking_system(&mut world, 10);
|
||||||
|
|
||||||
// Frozen entity should be unhidden and stay unhidden
|
// Frozen entity should be unhidden and stay unhidden
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
assert_that(&has_frozen_component(&world, entity)).is_true();
|
assert_that(&has_frozen_component(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +242,7 @@ fn test_blinking_system_frozen_entity_no_blinking() {
|
|||||||
run_blinking_system(&mut world, 10);
|
run_blinking_system(&mut world, 10);
|
||||||
|
|
||||||
// Frozen entity should not be hidden (blinking disabled)
|
// Frozen entity should not be hidden (blinking disabled)
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
assert_that(&has_frozen_component(&world, entity)).is_true();
|
assert_that(&has_frozen_component(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +270,7 @@ fn test_blinking_system_entity_without_renderable_ignored() {
|
|||||||
run_blinking_system(&mut world, 10);
|
run_blinking_system(&mut world, 10);
|
||||||
|
|
||||||
// Entity should not be affected (not in query)
|
// Entity should not be affected (not in query)
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -274,7 +289,7 @@ fn test_blinking_system_entity_without_blinking_ignored() {
|
|||||||
run_blinking_system(&mut world, 10);
|
run_blinking_system(&mut world, 10);
|
||||||
|
|
||||||
// Entity should not be affected (not in query)
|
// Entity should not be affected (not in query)
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -286,7 +301,7 @@ fn test_blinking_system_large_interval() {
|
|||||||
run_blinking_system(&mut world, 500);
|
run_blinking_system(&mut world, 500);
|
||||||
|
|
||||||
// Entity should not be hidden yet
|
// Entity should not be hidden yet
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
// Check that timer was updated
|
// Check that timer was updated
|
||||||
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
let blinking = world.entity(entity).get::<Blinking>().unwrap();
|
||||||
@@ -302,11 +317,11 @@ fn test_blinking_system_very_small_interval() {
|
|||||||
run_blinking_system(&mut world, 1);
|
run_blinking_system(&mut world, 1);
|
||||||
|
|
||||||
// Entity should be hidden
|
// Entity should be hidden
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_true();
|
assert_that(&is_entity_hidden(&world, entity)).is_true();
|
||||||
|
|
||||||
// Run system with another 1 tick
|
// Run system with another 1 tick
|
||||||
run_blinking_system(&mut world, 1);
|
run_blinking_system(&mut world, 1);
|
||||||
|
|
||||||
// Entity should be unhidden
|
// Entity should be unhidden
|
||||||
assert_that(&has_hidden_component(&world, entity)).is_false();
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,19 +36,17 @@ fn test_check_collision_helper() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_pacman_item() {
|
fn test_collision_system_pacman_item() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut schedule) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
||||||
|
|
||||||
// Run collision system - should not panic
|
// Run collision system - should not panic
|
||||||
world
|
schedule.run(&mut world);
|
||||||
.run_system_once(collision_system)
|
|
||||||
.expect("System should run successfully");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_pacman_ghost() {
|
fn test_collision_system_pacman_ghost() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
||||||
|
|
||||||
@@ -60,19 +58,17 @@ fn test_collision_system_pacman_ghost() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_no_collision() {
|
fn test_collision_system_no_collision() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut schedule) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node
|
let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node
|
||||||
|
|
||||||
// Run collision system - should not panic
|
// Run collision system - should not panic
|
||||||
world
|
schedule.run(&mut world);
|
||||||
.run_system_once(collision_system)
|
|
||||||
.expect("System should run successfully");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_multiple_entities() {
|
fn test_collision_system_multiple_entities() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
||||||
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use bevy_ecs::{entity::Entity, event::Events, world::World};
|
use bevy_ecs::{entity::Entity, event::Events, schedule::Schedule, world::World};
|
||||||
use glam::{U16Vec2, Vec2};
|
use glam::{U16Vec2, Vec2};
|
||||||
use pacman::{
|
use pacman::{
|
||||||
asset::{get_asset_bytes, Asset},
|
asset::{get_asset_bytes, Asset},
|
||||||
constants::RAW_BOARD,
|
constants::RAW_BOARD,
|
||||||
events::GameEvent,
|
events::{CollisionTrigger, GameEvent},
|
||||||
game::ATLAS_FRAMES,
|
game::ATLAS_FRAMES,
|
||||||
map::{
|
map::{
|
||||||
builder::Map,
|
builder::Map,
|
||||||
@@ -13,9 +13,9 @@ use pacman::{
|
|||||||
graph::{Graph, Node},
|
graph::{Graph, Node},
|
||||||
},
|
},
|
||||||
systems::{
|
systems::{
|
||||||
AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost, GhostCollider, GhostState,
|
item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost,
|
||||||
GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled, Position, ScoreResource,
|
GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled,
|
||||||
Velocity,
|
Position, ScoreResource, Velocity,
|
||||||
},
|
},
|
||||||
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
|
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
|
||||||
};
|
};
|
||||||
@@ -75,7 +75,7 @@ pub fn create_test_graph() -> Graph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a basic test world with required resources for ECS systems
|
/// Creates a basic test world with required resources for ECS systems
|
||||||
pub fn create_test_world() -> World {
|
pub fn create_test_world() -> (World, Schedule) {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
|
|
||||||
// Add required resources
|
// Add required resources
|
||||||
@@ -93,7 +93,11 @@ pub fn create_test_world() -> World {
|
|||||||
}); // 60 FPS
|
}); // 60 FPS
|
||||||
world.insert_resource(create_test_map());
|
world.insert_resource(create_test_map());
|
||||||
|
|
||||||
world
|
let schedule = Schedule::default();
|
||||||
|
|
||||||
|
world.add_observer(item_collision_observer);
|
||||||
|
|
||||||
|
(world, schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a test map using the default RAW_BOARD
|
/// Creates a test map using the default RAW_BOARD
|
||||||
@@ -163,9 +167,8 @@ pub fn send_game_event(world: &mut World, event: GameEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a collision event between two entities
|
/// Sends a collision event between two entities
|
||||||
pub fn send_collision_event(world: &mut World, entity1: Entity, entity2: Entity) {
|
pub fn trigger_collision(world: &mut World, event: CollisionTrigger) {
|
||||||
let mut events = world.resource_mut::<Events<GameEvent>>();
|
world.trigger(event);
|
||||||
events.send(GameEvent::Collision(entity1, entity2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a mock atlas tile for testing
|
/// Creates a mock atlas tile for testing
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use bevy_ecs::{entity::Entity, system::RunSystemOnce};
|
use bevy_ecs::entity::Entity;
|
||||||
use pacman::systems::{item_system, EntityType, GhostState, Position, ScoreResource};
|
use pacman::{
|
||||||
|
events::CollisionTrigger,
|
||||||
|
systems::{EntityType, GhostState, Position, ScoreResource},
|
||||||
|
};
|
||||||
use speculoos::prelude::*;
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
@@ -26,18 +29,16 @@ fn test_is_collectible_item() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_pellet_collection() {
|
fn test_item_system_pellet_collection() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
|
|
||||||
// Send collision event
|
// Send collision event
|
||||||
common::send_collision_event(&mut world, pacman, pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet });
|
||||||
|
|
||||||
// Run the item system
|
world.flush();
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
|
||||||
|
|
||||||
// Check that score was updated
|
// Check that score was updated
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource_mut::<ScoreResource>();
|
||||||
assert_that(&score.0).is_equal_to(10);
|
assert_that(&score.0).is_equal_to(10);
|
||||||
|
|
||||||
// Check that the pellet was despawned (query should return empty)
|
// Check that the pellet was despawned (query should return empty)
|
||||||
@@ -51,13 +52,12 @@ fn test_item_system_pellet_collection() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_power_pellet_collection() {
|
fn test_item_system_power_pellet_collection() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, power_pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Check that score was updated with power pellet value
|
// Check that score was updated with power pellet value
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
@@ -74,18 +74,17 @@ fn test_item_system_power_pellet_collection() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_multiple_collections() {
|
fn test_item_system_multiple_collections() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
let pellet2 = common::spawn_test_item(&mut world, 2, EntityType::Pellet);
|
let pellet2 = common::spawn_test_item(&mut world, 2, EntityType::Pellet);
|
||||||
let power_pellet = common::spawn_test_item(&mut world, 3, EntityType::PowerPellet);
|
let power_pellet = common::spawn_test_item(&mut world, 3, EntityType::PowerPellet);
|
||||||
|
|
||||||
// Send multiple collision events
|
// Send multiple collision events
|
||||||
common::send_collision_event(&mut world, pacman, pellet1);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet1 });
|
||||||
common::send_collision_event(&mut world, pacman, pellet2);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet2 });
|
||||||
common::send_collision_event(&mut world, pacman, power_pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Check final score: 2 pellets (20) + 1 power pellet (50) = 70
|
// Check final score: 2 pellets (20) + 1 power pellet (50) = 70
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
@@ -108,8 +107,7 @@ fn test_item_system_multiple_collections() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_ignores_non_item_collisions() {
|
fn test_item_system_ignores_non_item_collisions() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
|
|
||||||
// Create a ghost entity (not an item)
|
// Create a ghost entity (not an item)
|
||||||
let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id();
|
let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id();
|
||||||
@@ -118,9 +116,9 @@ fn test_item_system_ignores_non_item_collisions() {
|
|||||||
let initial_score = world.resource::<ScoreResource>().0;
|
let initial_score = world.resource::<ScoreResource>().0;
|
||||||
|
|
||||||
// Send collision event between pacman and ghost
|
// Send collision event between pacman and ghost
|
||||||
common::send_collision_event(&mut world, pacman, ghost);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: ghost });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Score should remain unchanged
|
// Score should remain unchanged
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
@@ -137,14 +135,14 @@ fn test_item_system_ignores_non_item_collisions() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_no_collision_events() {
|
fn test_item_system_no_collision_events() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let _pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
|
|
||||||
let initial_score = world.resource::<ScoreResource>().0;
|
let initial_score = world.resource::<ScoreResource>().0;
|
||||||
|
|
||||||
// Run system without any collision events
|
// Run system without any collision events
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Nothing should change
|
// Nothing should change
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
@@ -159,19 +157,15 @@ fn test_item_system_no_collision_events() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_collision_with_missing_entity() {
|
fn test_item_system_collision_with_missing_entity() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
|
|
||||||
// Create a fake entity ID that doesn't exist
|
// Create a fake entity ID that doesn't exist
|
||||||
let fake_entity = Entity::from_raw(999);
|
let fake_entity = Entity::from_raw(999);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, fake_entity);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: fake_entity });
|
||||||
|
|
||||||
// System should handle gracefully and not crash
|
// System should handle gracefully and not crash
|
||||||
world
|
world.flush();
|
||||||
.run_system_once(item_system)
|
|
||||||
.expect("System should handle missing entities gracefully");
|
|
||||||
|
|
||||||
// Score should remain unchanged
|
// Score should remain unchanged
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
assert_that(&score.0).is_equal_to(0);
|
assert_that(&score.0).is_equal_to(0);
|
||||||
@@ -179,17 +173,16 @@ fn test_item_system_collision_with_missing_entity() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_preserves_existing_score() {
|
fn test_item_system_preserves_existing_score() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
|
|
||||||
// Set initial score
|
// Set initial score
|
||||||
world.insert_resource(ScoreResource(100));
|
world.insert_resource(ScoreResource(100));
|
||||||
|
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Score should be initial + pellet value
|
// Score should be initial + pellet value
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
@@ -198,8 +191,7 @@ fn test_item_system_preserves_existing_score() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
|
fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
||||||
|
|
||||||
// Spawn a ghost in Eyes state (returning to ghost house)
|
// Spawn a ghost in Eyes state (returning to ghost house)
|
||||||
@@ -208,9 +200,9 @@ fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
|
|||||||
// Spawn a ghost in Normal state
|
// Spawn a ghost in Normal state
|
||||||
let normal_ghost = common::spawn_test_ghost(&mut world, 3, GhostState::Normal);
|
let normal_ghost = common::spawn_test_ghost(&mut world, 3, GhostState::Normal);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, power_pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Check that the power pellet was collected and score updated
|
// Check that the power pellet was collected and score updated
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ fn test_entity_type_traversal_flags() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_move_command() {
|
fn test_player_control_system_move_command() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send move command
|
// Send move command
|
||||||
@@ -141,7 +141,7 @@ fn test_player_control_system_move_command() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_exit_command() {
|
fn test_player_control_system_exit_command() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send exit command
|
// Send exit command
|
||||||
@@ -159,7 +159,7 @@ fn test_player_control_system_exit_command() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_toggle_debug() {
|
fn test_player_control_system_toggle_debug() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send toggle debug command
|
// Send toggle debug command
|
||||||
@@ -177,7 +177,7 @@ fn test_player_control_system_toggle_debug() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_mute_audio() {
|
fn test_player_control_system_mute_audio() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send mute audio command
|
// Send mute audio command
|
||||||
@@ -206,7 +206,7 @@ fn test_player_control_system_mute_audio() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_no_player_entity() {
|
fn test_player_control_system_no_player_entity() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
// Don't spawn a player entity
|
// Don't spawn a player entity
|
||||||
|
|
||||||
common::send_game_event(&mut world, GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
|
common::send_game_event(&mut world, GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
|
||||||
@@ -221,7 +221,7 @@ fn test_player_control_system_no_player_entity() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_buffered_direction_expires() {
|
fn test_player_movement_system_buffered_direction_expires() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set a buffered direction with short time
|
// Set a buffered direction with short time
|
||||||
@@ -251,7 +251,7 @@ fn test_player_movement_system_buffered_direction_expires() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_start_moving_from_stopped() {
|
fn test_player_movement_system_start_moving_from_stopped() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Player starts at node 0, facing right (towards node 1)
|
// Player starts at node 0, facing right (towards node 1)
|
||||||
@@ -276,7 +276,7 @@ fn test_player_movement_system_start_moving_from_stopped() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_buffered_direction_change() {
|
fn test_player_movement_system_buffered_direction_change() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set a buffered direction to go down (towards node 2)
|
// Set a buffered direction to go down (towards node 2)
|
||||||
@@ -307,7 +307,7 @@ fn test_player_movement_system_buffered_direction_change() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_no_valid_edge() {
|
fn test_player_movement_system_no_valid_edge() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set velocity to direction with no edge
|
// Set velocity to direction with no edge
|
||||||
@@ -332,7 +332,7 @@ fn test_player_movement_system_no_valid_edge() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_continue_moving() {
|
fn test_player_movement_system_continue_moving() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set player to already be moving
|
// Set player to already be moving
|
||||||
@@ -362,7 +362,7 @@ fn test_player_movement_system_continue_moving() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_full_player_input_to_movement_flow() {
|
fn test_full_player_input_to_movement_flow() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send move command
|
// Send move command
|
||||||
@@ -396,7 +396,7 @@ fn test_full_player_input_to_movement_flow() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_buffered_direction_timing() {
|
fn test_buffered_direction_timing() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send move command
|
// Send move command
|
||||||
@@ -435,7 +435,7 @@ fn test_buffered_direction_timing() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_multiple_rapid_direction_changes() {
|
fn test_multiple_rapid_direction_changes() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send multiple rapid direction changes
|
// Send multiple rapid direction changes
|
||||||
@@ -468,7 +468,7 @@ fn test_multiple_rapid_direction_changes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_state_persistence_across_systems() {
|
fn test_player_state_persistence_across_systems() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Test that multiple commands can be processed - but need to handle events properly
|
// Test that multiple commands can be processed - but need to handle events properly
|
||||||
|
|||||||
Reference in New Issue
Block a user