Compare commits

..
Author SHA1 Message Date
EIREXE 15052e7e16 Expose LLVM tools to Windows build
CI / production-build (pull_request) Successful in 22m8s
CI / build-and-test (pull_request) Successful in 1m17s
2026-09-19 23:14:03 +02:00
EIREXE 48896d5c1e Use Clang 19 for Windows cross-builds
CI / build-and-test (pull_request) Successful in 1m22s
CI / production-build (pull_request) Failing after 14m59s
2026-09-19 22:53:19 +02:00
EIREXE 350b37a4da Fix blake build
CI / build-and-test (pull_request) Successful in 6m54s
CI / production-build (pull_request) Failing after 14m15s
2026-09-19 22:28:04 +02:00
EIREXE 4d474227cc Install Windows tooling in production job
CI / build-and-test (pull_request) Successful in 3m10s
CI / production-build (pull_request) Failing after 8m23s
2026-09-19 22:15:16 +02:00
EIREXE b5cc5428d2 Bump gc driver 2026-09-19 22:12:35 +02:00
EIREXE 49b23110ff Build Windows binary in shared Steam depot
CI / build-and-test (pull_request) Successful in 1m46s
CI / production-build (pull_request) Failing after 4m50s
2026-09-19 22:01:52 +02:00
100 changed files with 83140 additions and 5121 deletions
-75
View File
@@ -15,27 +15,6 @@ jobs:
- name: Check out source - name: Check out source
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Lore CLI
run: |
scripts/install-lore.sh "$RUNNER_TEMP/lore-bin"
echo "$RUNNER_TEMP/lore-bin" >> "$GITHUB_PATH"
- name: Select Lore asset branch
run: |
branch=main
if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then
branch="$(python3 -c 'import json, os; e=json.load(open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8")); p=e["pull_request"]; print(p["head"]["ref"] if p["head"]["repo"]["full_name"] == os.environ["GITHUB_REPOSITORY"] else "main")')"
fi
echo "LORE_BRANCH=$branch" >> "$GITHUB_ENV"
echo "Using Lore branch: $branch"
- name: Fetch assets from Lore
env:
LORE_CI_USERNAME: ${{ secrets.LORE_CI_USERNAME }}
LORE_CI_PASSWORD: ${{ secrets.LORE_CI_PASSWORD }}
LORE_AUTH_URL: https://auth.eirexe.moe
run: scripts/update-assets.sh "$LORE_BRANCH"
- name: Install Rust toolchain - name: Install Rust toolchain
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
@@ -79,27 +58,6 @@ jobs:
- name: Check out source - name: Check out source
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Lore CLI
run: |
scripts/install-lore.sh "$RUNNER_TEMP/lore-bin"
echo "$RUNNER_TEMP/lore-bin" >> "$GITHUB_PATH"
- name: Select Lore asset branch
run: |
branch=main
if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then
branch="$(python3 -c 'import json, os; e=json.load(open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8")); p=e["pull_request"]; print(p["head"]["ref"] if p["head"]["repo"]["full_name"] == os.environ["GITHUB_REPOSITORY"] else "main")')"
fi
echo "LORE_BRANCH=$branch" >> "$GITHUB_ENV"
echo "Using Lore branch: $branch"
- name: Fetch assets from Lore
env:
LORE_CI_USERNAME: ${{ secrets.LORE_CI_USERNAME }}
LORE_CI_PASSWORD: ${{ secrets.LORE_CI_PASSWORD }}
LORE_AUTH_URL: https://auth.eirexe.moe
run: scripts/update-assets.sh "$LORE_BRANCH"
- name: Install Rust toolchain - name: Install Rust toolchain
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
@@ -129,11 +87,8 @@ jobs:
libusb-1.0-0-dev \ libusb-1.0-0-dev \
libwayland-dev \ libwayland-dev \
libx11-dev \ libx11-dev \
libxfixes3 \
libxi6 \
libxkbcommon-dev \ libxkbcommon-dev \
libxkbcommon-x11-0 \ libxkbcommon-x11-0 \
blender \
lld-19 \ lld-19 \
llvm-19 \ llvm-19 \
mesa-vulkan-drivers \ mesa-vulkan-drivers \
@@ -144,39 +99,9 @@ jobs:
echo "/usr/lib/llvm-19/bin" >> "$GITHUB_PATH" echo "/usr/lib/llvm-19/bin" >> "$GITHUB_PATH"
rm -f "$HOME/.cache/cargo-xwin/clang-cl" rm -f "$HOME/.cache/cargo-xwin/clang-cl"
- name: Install pinned Blender
run: |
scripts/install-blender.sh "$RUNNER_TEMP/blender"
echo "BLENDER=$RUNNER_TEMP/blender/blender" >> "$GITHUB_ENV"
- name: Preprocess assets - name: Preprocess assets
run: cargo run --locked --bin process-assets --no-default-features --features debug_features -j 4 run: cargo run --locked --bin process-assets --no-default-features --features debug_features -j 4
- name: Verify processed battlefield asset
run: |
python3 - <<'PY'
import json
import struct
path = "imported_assets/Default/stages/battlefield/bf2.blend"
with open(path, "rb") as asset:
header = asset.read(12)
if len(header) != 12:
raise SystemExit(f"processed asset is truncated: {path}")
magic, version, length = struct.unpack("<4sII", header)
if magic != b"glTF" or version != 2 or length != asset.seek(0, 2):
raise SystemExit(f"processed asset is not a valid GLB: {path}")
asset.seek(12)
json_length, chunk_type = struct.unpack("<II", asset.read(8))
if chunk_type != 0x4E4F534A:
raise SystemExit(f"processed asset has no JSON chunk: {path}")
document = json.loads(asset.read(json_length))
meshes = document.get("meshes", [])
if not meshes:
raise SystemExit(f"processed asset contains no meshes: {path}")
print(f"{path}: {len(meshes)} meshes, {length} bytes")
PY
- name: Build production binary - name: Build production binary
run: cargo build --locked --profile production --no-default-features --features debug_features -j 4 run: cargo build --locked --profile production --no-default-features --features debug_features -j 4
-5
View File
@@ -1,7 +1,6 @@
/target /target
/.jackdaw /.jackdaw
imported_assets imported_assets
/assets/
# Local editor configuration # Local editor configuration
**/.vscode/ **/.vscode/
@@ -11,7 +10,3 @@ imported_assets
*_old.glb *_old.glb
/replay.json /replay.json
*.replay.tmp *.replay.tmp
# Python debugger formatter tests
__pycache__/
*.pyc
+2 -2
View File
@@ -8,11 +8,11 @@ authors = [""]
[features] [features]
default = ["development", "debug_features"] default = ["development", "debug_features"]
development = ["bevy/dynamic_linking", "bevy/file_watcher", "bevy/embedded_watcher"] development = ["bevy/dynamic_linking", "bevy/file_watcher"]
debug_features = ["bevy/free_camera"] debug_features = ["bevy/free_camera"]
[dependencies] [dependencies]
bevy = { version = "0.19", features = ["asset_processor", "basis-universal", "compressed_image_saver", "bevy_settings"] } bevy = { version = "0.19", features = ["asset_processor", "basis-universal", "bevy_settings"] }
# Loads the scenes you author in the editor (assets/*.bsn) and builds # Loads the scenes you author in the editor (assets/*.bsn) and builds
# physics colliders from them. An ordinary dependency; remove it if you # physics colliders from them. An ordinary dependency; remove it if you
# stop using authored scenes. # stop using authored scenes.
+8 -25
View File
@@ -2,15 +2,6 @@
Fancy heavily advanced platform fighting game Fancy heavily advanced platform fighting game
## Debugging fixed-point values with lldb
If using CodeLLDB:
```json
"preRunCommands": [
"command script import ${workspaceFolder}/tools/lldb_fixed.py"
]
```
## Direct P2P Debugging ## Direct P2P Debugging
```text ```text
@@ -25,6 +16,11 @@ cargo run -- --debug-match --players 2 --local-port 7001 \
## Production Build ## Production Build
Production builds use the `production` Cargo profile and do not dynamically
link Bevy or Rust. Development builds retain Bevy dynamic linking for faster
iteration. Cargo stores the production profile under `target/production`,
separate from development artifacts under `target/debug`.
```sh ```sh
cargo run --locked \ cargo run --locked \
--bin process-assets --no-default-features --features debug_features --bin process-assets --no-default-features --features debug_features
@@ -32,19 +28,6 @@ cargo build --locked \
--profile production --no-default-features --features debug_features --profile production --no-default-features --features debug_features
``` ```
## Assets The asset preprocessing command must be run from the repository root. It
populates `imported_assets/Default`, which is then consumed by the production
Game assets are managed in Lore rather than Git. Install the Lore CLI, then game without running the asset processor at startup.
bootstrap or update the local checkout with:
```sh
scripts/install-lore.sh
scripts/update-assets.sh
```
The default Lore branch is `main`; pass another branch explicitly when testing
coordinated code and asset changes:
```sh
scripts/update-assets.sh my-feature-branch
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,12 @@
frame(4);
hitbox(0,
"hand_l",
damage("5.0"),
vec3("0.0", "0.0", "0.0"),
fg32("5.0"),
angle_sakurai(),
knockback_normal(),
knockback("10.0"),
fg32("1.0")
);
frame(8);
@@ -0,0 +1,12 @@
frame(3);
hitbox(0,
"hand_l",
damage("5.0"),
vec3("0.0", "0.0", "0.0"),
fg32("5.0"),
angle(fg32("45.0")),
knockback_normal(),
knockback("18.0"),
fg32("140.0")
);
frame(8);
@@ -0,0 +1,13 @@
frame(9);
hitbox(0,
"hand_l",
damage("5.0"),
vec3("1.0", "0.0", "0.0"),
fg32("7.0"),
angle_sakurai(),
knockback_normal(),
knockback("10.0"),
fg32("1.0")
);
frame(15);
+219
View File
@@ -0,0 +1,219 @@
(
id: TestFighter,
model_path: "fighters/test/test_fighter.gltf",
model_scale: "10.0",
baked_animation_path: "fighters/test/test.fighterbake",
icon: (
path: "fighters/test/icon.png",
),
attributes: (
base_walk_accel: "0.1",
stick_walk_accel: "0.16",
max_walk_vel: "1.0",
ground_friction: "0.1",
dash_acceleration_duration: 13,
dash_duration: 24,
base_dash_accel: "0.1",
stick_dash_accel: "0.05",
max_dash_vel: "2.0",
short_hop_vertical_velocity: "1.45",
full_jump_vertical_velocity: "2.0",
jump_horizontal_velocity: "0.6",
air_jump_horizontal_velocity: "0.96",
air_jump_multiplier: "1.0",
dash_initial_velocity: "1.4",
terminal_velocity: "3.0",
fast_fall_speed: "3.5",
gravity: "0.084",
weight: "100.0",
jumpsquat_duration: 5,
landing_iasa: 4,
landing_duration: 30,
max_air_horizontal_velocity: "2.5",
air_drift_max: "1.0",
air_acceleration_base: "0.03",
air_acceleration_stick: "0.01",
air_friction: "0.01",
air_dodge_duration: 49,
air_dodge_velocity: "2.790",
air_dodge_decay: "0.9",
ecb_vertical_midpoint_offset: "0.0"
),
camera: (
vertical_origin_offset: "8.0",
forward_extent: "25.5",
backward_extent: "8.5",
upward_extent: "15.0",
downward_extent: "5.0",
visibility_radius: "5.0",
),
animations: {
Wait: "Idle_Loop_Game",
Walk: "Walk_Loop_IK_Game",
Run: "Run_Game",
Dash: "Dash_Game",
JumpSquat: "Jump_Squat_Game",
JumpForward: "Jump_Start_IK_Game",
JumpBack: "JumpB_Game",
LandingSpecial: "SpecialLand_Game",
Landing: "Jump_Land_Game",
AirDodge: "Idle_Loop_Game",
DoubleJumpForward: "Jump_Start_IK_Game",
DoubleJumpBack: "JumpB_Game",
Fall: "Fall_Loop_Game",
Turn: "Turn_Game",
FlinchGround: "Air_Hit_Game",
FlinchAir: "Air_Hit_Game",
AttackJab1: "Punch_Jab_Game",
AttackUpTilt: "Idle_Loop_Game",
AttackForwardTilt: "Idle_Loop_Game",
AttackDownTilt: "Idle_Loop_Game"
},
attack_scripts: {
NeutralAir: "fighters/test/scripts/fair.attack.rhai",
Jab: "fighters/test/scripts/jab.attack.rhai",
UpTilt: "fighters/test/scripts/uptilt.attack.rhai",
ForwardTilt: "fighters/test/scripts/uptilt.attack.rhai",
DownTilt: "fighters/test/scripts/uptilt.attack.rhai",
},
hurtboxes: [
// center one
(
bone: "spine_01",
offset: (x: "0.0", y: "0.0", z: "0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.05",
radius: "0.15"
),
(
bone: "spine_03",
offset: (x: "0.0", y: "0.0", z: "0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.05",
radius: "0.15"
),
(
bone: "Head",
offset: (x: "0.0", y: "0.125", z: "0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.05",
radius: "0.1"
),
(
bone: "thigh_r",
offset: (x: "0.0", y: "0.15", z: "-0.01"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.2",
radius: "0.07"
),
(
bone: "thigh_l",
offset: (x: "0.0", y: "0.15", z: "-0.01"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.2",
radius: "0.07"
),
(
bone: "calf_l",
offset: (x: "0.0", y: "0.15", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.2",
radius: "0.05"
),
(
bone: "calf_r",
offset: (x: "0.0", y: "0.15", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.2",
radius: "0.05"
),
(
bone: "foot_r",
offset: (x: "0.0", y: "0.1", z: "0.01"),
rotation: FGVec3(x: "-25.0", y: "0.0", z: "0.0"),
half_length: "0.1",
radius: "0.04"
),
(
bone: "foot_l",
offset: (x: "0.0", y: "0.1", z: "0.01"),
rotation: FGVec3(x: "-25.0", y: "0.0", z: "0.0"),
half_length: "0.1",
radius: "0.04"
),
(
bone: "upperarm_l",
offset: (x: "0.0", y: "0.09", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.125",
radius: "0.05"
),
(
bone: "lowerarm_l",
offset: (x: "0.0", y: "0.12", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.115",
radius: "0.04"
),
(
bone: "hand_l",
offset: (x: "0.0", y: "0.05", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.01",
radius: "0.04"
),
(
bone: "upperarm_r",
offset: (x: "0.0", y: "0.09", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.125",
radius: "0.05"
),
(
bone: "lowerarm_r",
offset: (x: "0.0", y: "0.12", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.115",
radius: "0.04"
),
(
bone: "hand_r",
offset: (x: "0.0", y: "0.05", z: "-0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "0.0"),
half_length: "0.01",
radius: "0.04"
),
/*(
bone: "RFootJA",
offset: (x: "0.0", y: "0.5", z: "0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "90.0"),
half_length: "0.5",
radius: "1.0"
),
(
bone: "JOBJ_47",
offset: (x: "0.0", y: "0.0", z: "0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "90.0"),
half_length: "0.5",
radius: "1.0"
),
(
bone: "LFootJA",
offset: (x: "0.0", y: "0.5", z: "0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "90.0"),
half_length: "0.5",
radius: "1.0"
),
(
bone: "JOBJ_41",
offset: (x: "0.0", y: "0.0", z: "0.0"),
rotation: FGVec3(x: "0.0", y: "0.0", z: "90.0"),
half_length: "0.5",
radius: "1.0"
),*/
]
)
+3
View File
@@ -0,0 +1,3 @@
(
manifest_path: "fighters/test/test.fighter.ron",
)
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+128
View File
@@ -0,0 +1,128 @@
(
cursor: (
path: "ui/textures/cursor.png",
sampling: Nearest
),
css_token: (
path: "ui/textures/fighter_select_token.png",
sampling: Linear
),
player_colors: [
Srgba((red: 0.780, green: 0.420, blue: 0.933, alpha: 1.000)),
Srgba((red: 0.843, green: 0.227, blue: 0.204, alpha: 1.000)),
Srgba((red: 0.333, green: 0.804, blue: 0.078, alpha: 1.000)),
Srgba((red: 0.075, green: 0.376, blue: 0.416, alpha: 1.000)),
Srgba((red: 0.682, green: 0.878, blue: 0.914, alpha: 1.000))
],
button_styles: {
MainMenu: (
focus_outline_width_px: 4,
focus_outline_offset_px: 2,
focus_outline_color: Srgba((red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)),
normal: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
press: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
hover: (
background_color: Srgba((red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
)
),
CharacterSelectCharacter: (
focus_outline_width_px: 10,
focus_outline_offset_px: 5,
focus_outline_color: Srgba((red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)),
normal: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
border_size: 10,
),
press: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
hover: (
background_color: Srgba((red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
)
),
CharacterSelectStartBanner: (
focus_outline_width_px: 10,
focus_outline_offset_px: 5,
focus_outline_color: Srgba((red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)),
normal: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
press: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
hover: (
background_color: Srgba((red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
)
),
StageSelectIcon: (
focus_outline_width_px: 4,
focus_outline_offset_px: 2,
focus_outline_color: Srgba((red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)),
normal: (
border_color: Srgba((red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)),
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 0.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_size: 10,
border_radius: 40.0,
box_shadow: (
spread_radius: 5.0,
blur_radius: 5.0,
offset_px_y: 10,
offset_px_x: 10,
color: Srgba((red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0))
),
),
press: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_radius: 40.0,
border_size: 10,
box_shadow: (
spread_radius: 5.0,
offset_px_y: 10,
offset_px_x: 10,
color: Srgba((red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0))
),
),
hover: (
background_color: Srgba((red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_radius: 40.0,
border_size: 10,
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (
spread_radius: 5.0,
offset_px_y: 10,
offset_px_x: 10,
color: Srgba((red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0))
),
)
),
}
)
+36
View File
@@ -0,0 +1,36 @@
(
input_common: (
stick_deadzone: "0.275",
trigger_deadzone: "0.3",
smash_input_reset_axis_threshold: "0.25",
smash_input_axis_threshold: "0.8",
run_stick_threshold: "0.625",
smash_input_frame_threshold: 3,
input_buffer_size: 3,
turn_into_dashback_window: 2,
),
fighter_common: (
walk_speed_ease: "0.5",
ground_max_horizontal_velocity: "3.0",
ground_friction_over_walk_speed_multiplier: "2.0",
knockback_stacking_window: 2,
knockback_decay_per_frame: "0.051",
knockback_launch_velocity_multiplier: "0.03",
hitstun_frames_per_unit_knockback: "0.4",
tumble_bounce_hitstop_threshold: 32,
attack_ground_to_airborne_transition_vertical_angle_threshold: "90.0",
meteor_angle_threshold: "10.0",
visual_shake_frames_per_hitstop_frame: "1.2",
visual_shake_table: [
-3.0, -1.5, 1.5, 3.0
],
attacker_visual_shake_multiplier: 0.4,
di_max_angle: "18.0"
),
stage_manifest_paths: [
"stages/battlefield/test.stage.ron"
],
fighter_manifest_paths: [
"fighters/test/test.fighter.ron"
]
)
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+139
View File
@@ -0,0 +1,139 @@
(
id: TestStage,
model_path: "stages/battlefield/battlefield.glb#Scene0",
icon: (
path: "stages/battlefield/icon.png"
),
// Battlefield
/*camera: (
left: -200.0,
right: 200.0,
bottom: -59.0,
top: 170.0,
origin: (0.0, 44.0),
vertical_fov_degrees: 30.0,
min_depth: 83.0,
max_depth: 1000.0,
subject_scale: 1.5,
fighter_forward_extent_scale: 1.5,
tracking_smoothness: 1.8,
vertical_pan_degrees: -10.0,
horizontal_pan_degrees_per_unit: 0.1,
vertical_pan_degrees_per_unit: 0.1,
max_horizontal_pan_degrees: 17.5,
max_upward_pan_degrees: 5.0,
max_downward_pan_degrees: 7.0,
),*/
// Yoshi's story
/*camera: (
left: -180.0,
right: 179.0,
bottom: -71.0,
top: 169.0,
origin: (0.0, 44.0),
vertical_fov_degrees: 20.0,
min_depth: 100.0,
max_depth: 1000.0,
subject_scale: 1.0,
fighter_forward_extent_scale: 1.0,
tracking_smoothness: 1.0,
vertical_pan_degrees: -10.0,
horizontal_pan_degrees_per_unit: 0.05,
vertical_pan_degrees_per_unit: 0.15,
max_horizontal_pan_degrees: 17.5,
max_upward_pan_degrees: 5.0,
max_downward_pan_degrees: 7.0,
),*/
camera: (
left: -180.0,
right: 179.0,
bottom: -71.0,
top: 169.0,
origin: (0.0, 44.0),
vertical_fov_degrees: 20.0,
min_depth: 100.0,
max_depth: 1000.0,
subject_scale: 1.5,
fighter_forward_extent_scale: 1.0,
tracking_smoothness: 1.0,
vertical_pan_degrees: -10.0,
horizontal_pan_degrees_per_unit: 0.05,
vertical_pan_degrees_per_unit: 0.15,
max_horizontal_pan_degrees: 17.5,
max_upward_pan_degrees: 5.0,
max_downward_pan_degrees: 7.0,
),
polygons: [
(
poly_type: Platform,
lines: [
(
(x: "-20.0", y: "47.0"),
Floor
),
(
(x: "20.0", y: "47.0"),
Floor
)
]
),
(
poly_type: Platform,
lines: [
(
(x: "-65.0", y: "24.0"),
Floor
),
(
(x: "-25.0", y: "24.0"),
Floor
)
]
),
(
poly_type: Platform,
lines: [
(
(x: "25.0", y: "24.0"),
Floor
),
(
(x: "65.0", y: "24.0"),
Floor
)
]
),
(
poly_type: Closed,
lines: [
(
(x: "-68.0", y: "-3.0"),
Wall
),
(
(x: "-68.0", y: "0.0"),
Floor,
),
(
(x: "0.0", y: "0.0"),
Floor
),
(
(x: "68", y: "0.0"),
Wall
),
(
(x: "68.0", y: "-3.0"),
Ceiling
),
(
(x: "0.0", y: "-15.0"),
Ceiling
),
]
)
]
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 33.866666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="cursor.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#000000"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="5.6568542"
inkscape:cx="8.6620581"
inkscape:cy="67.086756"
inkscape:window-width="2560"
inkscape:window-height="1006"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<linearGradient
id="linearGradient1"
inkscape:collect="always">
<stop
style="stop-color:#949494;stop-opacity:1;"
offset="0"
id="stop1" />
<stop
style="stop-color:#6c6c6c;stop-opacity:1;"
offset="0.77473962"
id="stop3" />
<stop
style="stop-color:#9f9f9f;stop-opacity:1;"
offset="1"
id="stop2" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1"
id="radialGradient2"
cx="16.933332"
cy="16.933332"
fx="16.933332"
fy="16.933332"
r="12.733072"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.2093771,0,0,1.2093771,-3.5454518,-3.5454518)" />
</defs>
<g
inkscape:label="Capa 1"
inkscape:groupmode="layer"
id="layer1">
<circle
style="fill:url(#radialGradient2);stroke-width:0.319981"
id="path1"
cx="16.933332"
cy="16.933332"
r="15.399086" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
version="5.2.2"
sha256="84098912789dc450e95697c4184fb8a90acbe5111c2ba4aede3fecb57806a168"
install_dir="${1:-${BLENDER_INSTALL_DIR:-${HOME}/.local/blender-${version}}}"
if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then
echo "error: this installer currently supports Linux x86_64 only" >&2
exit 1
fi
if [[ -x "$install_dir/blender" ]]; then
"$install_dir/blender" --version | head -n 1
exit 0
fi
mkdir -p "$(dirname "$install_dir")"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
archive="blender-${version}-linux-x64.tar.xz"
url="https://download.blender.org/release/Blender5.2/${archive}"
curl --fail --location --silent --show-error --output "$tmp_dir/$archive" "$url"
printf '%s %s\n' "$sha256" "$tmp_dir/$archive" | sha256sum --check --status
tar --extract --xz --file "$tmp_dir/$archive" --directory "$tmp_dir"
mv "$tmp_dir/blender-${version}-linux-x64" "$install_dir"
"$install_dir/blender" --version | head -n 1
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
version="v0.10.0"
sha256="0d1633b71e44081c6a3ca57ad5be3df7a9563698827638838939c3c832e87351"
install_dir="${1:-${LORE_INSTALL_DIR:-${HOME}/.local/bin}}"
if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then
echo "error: this installer currently supports Linux x86_64 only" >&2
exit 1
fi
mkdir -p "$install_dir"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
archive="lore-${version}-x86_64-unknown-linux-gnu.tar.gz"
url="https://github.com/EpicGames/lore/releases/download/${version}/${archive}"
curl --fail --location --silent --show-error --output "$tmp_dir/$archive" "$url"
printf '%s %s\n' "$sha256" "$tmp_dir/$archive" | sha256sum --check --status
tar --extract --gzip --file "$tmp_dir/$archive" --directory "$tmp_dir"
binary="$(find "$tmp_dir" -type f -name lore -print -quit)"
if [[ -z "$binary" ]]; then
echo "error: Lore archive did not contain a lore binary" >&2
exit 1
fi
install -m 0755 "$binary" "$install_dir/lore"
"$install_dir/lore" --version
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
readonly lore_url="${LORE_REPOSITORY_URL:-lore://lore.eirexe.moe/shinespark}"
readonly default_branch="${LORE_DEFAULT_BRANCH:-main}"
readonly assets_dir="${LORE_ASSETS_DIR:-assets}"
branch="${1:-${LORE_BRANCH:-$default_branch}}"
case "$branch" in
""|*[!A-Za-z0-9._/-]*)
echo "error: invalid Lore branch name: $branch" >&2
exit 2
;;
esac
if [[ -n "${LORE_IDENTITY_TOKEN:-}" ]]; then
lore_args=(--identity-token "$LORE_IDENTITY_TOKEN")
elif [[ -n "${LORE_CI_USERNAME:-}" || -n "${LORE_CI_PASSWORD:-}" ]]; then
if [[ -z "${LORE_CI_USERNAME:-}" || -z "${LORE_CI_PASSWORD:-}" ]]; then
echo "error: LORE_CI_USERNAME and LORE_CI_PASSWORD must be provided together" >&2
exit 2
fi
: "${LORE_AUTH_URL:=https://auth.eirexe.moe}"
export LORE_AUTH_URL
token="$(python3 - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
request = urllib.request.Request(
os.environ["LORE_AUTH_URL"].rstrip("/") + "/auth/login",
data=json.dumps({
"username": os.environ["LORE_CI_USERNAME"],
"password": os.environ["LORE_CI_PASSWORD"],
}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request) as response:
payload = json.load(response)
except urllib.error.HTTPError as error:
print(f"Lore authentication failed with HTTP {error.code}", file=sys.stderr)
sys.exit(1)
token = payload.get("token")
if not isinstance(token, str) or not token:
print("Lore authentication response did not contain a token", file=sys.stderr)
sys.exit(1)
print(token)
PY
)"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "::add-mask::$token"
fi
lore_args=(--identity-token "$token")
else
lore_args=()
fi
if [[ -e "$assets_dir" && ! -d "$assets_dir" ]]; then
echo "error: $assets_dir exists but is not a directory" >&2
exit 1
fi
if [[ -d "$assets_dir/.lore" ]]; then
if ! lore "${lore_args[@]}" --repository "$assets_dir" repository info >/dev/null; then
echo "error: $assets_dir is not a usable Lore checkout" >&2
exit 1
fi
if ! lore "${lore_args[@]}" --repository "$assets_dir" branch list --no-pager \
| grep -Fqx " $branch"; then
if [[ "$branch" != "$default_branch" ]]; then
echo "warning: Lore branch does not exist: $branch; using $default_branch" >&2
branch="$default_branch"
else
echo "error: Lore branch does not exist: $branch" >&2
exit 1
fi
fi
lore "${lore_args[@]}" --repository "$assets_dir" sync "$branch@LATEST"
exit 0
fi
if [[ -d "$assets_dir" && -n "$(find "$assets_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
echo "error: refusing to overwrite non-Lore directory: $assets_dir" >&2
exit 1
fi
metadata_dir="$(mktemp -d)"
trap 'rm -rf "$metadata_dir"' EXIT
lore "${lore_args[@]}" --non-interactive clone --bare "$lore_url" "$metadata_dir"
if ! lore "${lore_args[@]}" --repository "$metadata_dir" branch list --no-pager \
| grep -Fqx " $branch"; then
if [[ "$branch" != "$default_branch" ]]; then
echo "warning: Lore branch does not exist: $branch; using $default_branch" >&2
branch="$default_branch"
else
echo "error: Lore branch does not exist: $branch" >&2
echo "available branches:" >&2
lore "${lore_args[@]}" --repository "$metadata_dir" branch list --no-pager >&2
exit 1
fi
fi
rm -rf "$assets_dir"
lore "${lore_args[@]}" --non-interactive clone --branch "$branch" "$lore_url" "$assets_dir"
lore "${lore_args[@]}" --repository "$assets_dir" status --revision-only --no-pager
-394
View File
@@ -1,394 +0,0 @@
//! Blender `.blend` source processing.
//!
//! Blender is used as an offline importer. The processed result is a normal
//! GLB, so the runtime continues to use Bevy's built-in glTF loader.
use bevy::{
asset::{
AssetLoader, AssetPath, AsyncWriteExt, LoadContext,
io::{Reader, Writer},
processor::LoadTransformAndSave,
saver::{AssetSaver, SavedAsset},
transformer::{AssetTransformer, TransformedAsset},
},
gltf::GltfLoader,
prelude::*,
reflect::TypePath,
};
use serde::Deserialize;
use std::{
collections::HashMap,
fs, io,
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
use crate::fighter::animation::AnimKind;
const EXPORT_SCRIPT: &str = include_str!("blend_asset/export.py");
#[derive(Asset, TypePath)]
struct BlendSource {
glb: Vec<u8>,
}
#[derive(Default, TypePath)]
struct BlendSourceLoader;
#[derive(Debug, Deserialize)]
struct ExportManifest {
dependencies: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct AnimationManifest {
animations: HashMap<AnimKind, String>,
}
impl AssetLoader for BlendSourceLoader {
type Asset = BlendSource;
type Settings = ();
type Error = io::Error;
async fn load(
&self,
reader: &mut dyn Reader,
_: &Self::Settings,
load_context: &mut LoadContext<'_>,
) -> Result<Self::Asset, Self::Error> {
let mut source_bytes = Vec::new();
reader.read_to_end(&mut source_bytes).await?;
let temp = TempDir::new()?;
let source_path = locate_source(load_context.path().path());
let blend_path = if let Some(path) = source_path.as_deref() {
path.to_path_buf()
} else {
let path = temp.path().join("source.blend");
fs::write(&path, source_bytes)?;
path
};
let output = temp.path().join("export.gltf");
let script = temp.path().join("export.py");
let actions = referenced_actions(load_context, &asset_root()).await?;
let actions_path = temp.path().join("actions.json");
fs::write(
&actions_path,
serde_json::to_vec(&actions).map_err(io::Error::other)?,
)?;
fs::write(&script, EXPORT_SCRIPT)?;
let result = Command::new(std::env::var_os("BLENDER").unwrap_or_else(|| "blender".into()))
.arg("--background")
.arg(&blend_path)
.arg("--python")
.arg(&script)
.arg("--")
.arg(&output)
.arg(&actions_path)
.arg(&blend_path)
.output()
.map_err(|error| io::Error::other(format!("failed to start Blender: {error}")))?;
if !result.status.success() {
return Err(io::Error::other(format!(
"Blender export failed with {}:\n{}\n{}",
result.status,
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
)));
}
let manifest: ExportManifest =
serde_json::from_slice(&fs::read(temp.path().join("export-manifest.json"))?)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let asset_root = asset_root();
let mut dependencies = manifest.dependencies;
let document: serde_json::Value = serde_json::from_slice(&fs::read(&output)?)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
validate_export(&document, &output)?;
if let Some(images) = document.get("images").and_then(serde_json::Value::as_array) {
for image in images {
if let Some(uri) = image.get("uri").and_then(serde_json::Value::as_str) {
let path = temp.path().join(uri);
dependencies.push(path.to_string_lossy().into_owned());
}
}
}
let mut seen = std::collections::HashSet::new();
for dependency in dependencies {
let physical = PathBuf::from(&dependency);
let Some(asset_path) = physical_to_asset_path(&physical, &asset_root) else {
continue;
};
if seen.insert(asset_path.clone()) {
// This read is intentionally discarded: it registers the file and
// its processed hash as a processor dependency.
load_context
.read_asset_bytes(asset_path)
.await
.map_err(|error| {
io::Error::other(format!("failed to read blend dependency: {error}"))
})?;
}
}
let glb = make_glb(&output, &temp, load_context.path().path(), &asset_root)?;
Ok(BlendSource { glb })
}
fn extensions(&self) -> &[&str] {
&["blend"]
}
}
fn validate_export(document: &serde_json::Value, output: &Path) -> io::Result<()> {
for key in ["scenes", "nodes", "meshes"] {
let count = document
.get(key)
.and_then(serde_json::Value::as_array)
.map_or(0, Vec::len);
if count == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Blender export {} contains no {key}", output.display()),
));
}
}
Ok(())
}
async fn referenced_actions(
load_context: &mut LoadContext<'_>,
root: &Path,
) -> io::Result<Vec<String>> {
let model_path = load_context.path().path();
let Some(stem) = model_path.file_stem().and_then(|stem| stem.to_str()) else {
return Ok(Vec::new());
};
let manifest_path = model_path
.parent()
.unwrap_or_else(|| Path::new(""))
.join(format!("{stem}.fighter.ron"));
if !root.join(&manifest_path).is_file() {
return Ok(Vec::new());
}
let bytes = load_context
.read_asset_bytes(manifest_path)
.await
.map_err(|error| io::Error::other(error.to_string()))?;
let manifest: AnimationManifest = ron::de::from_bytes(&bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
Ok(manifest.animations.into_values().collect())
}
#[derive(Asset, TypePath)]
struct ProcessedBlend {
bytes: Vec<u8>,
}
#[derive(Default, TypePath)]
struct BlendTransformer;
impl AssetTransformer for BlendTransformer {
type AssetInput = BlendSource;
type AssetOutput = ProcessedBlend;
type Settings = ();
type Error = io::Error;
async fn transform<'a>(
&'a self,
source: TransformedAsset<Self::AssetInput>,
_: &'a Self::Settings,
) -> Result<TransformedAsset<Self::AssetOutput>, Self::Error> {
let bytes = source.glb.clone();
Ok(source.replace_asset(ProcessedBlend { bytes }))
}
}
#[derive(Default, TypePath)]
struct BlendSaver;
impl AssetSaver for BlendSaver {
type Asset = ProcessedBlend;
type Settings = ();
type OutputLoader = GltfLoader;
type Error = io::Error;
async fn save(
&self,
writer: &mut Writer,
asset: SavedAsset<'_, '_, Self::Asset>,
_: &Self::Settings,
_: AssetPath<'_>,
) -> Result<<Self::OutputLoader as AssetLoader>::Settings, Self::Error> {
writer.write_all(&asset.bytes).await?;
Ok(default())
}
}
type BlendProcessor = LoadTransformAndSave<BlendSourceLoader, BlendTransformer, BlendSaver>;
pub struct BlendAssetProcessingPlugin;
impl Plugin for BlendAssetProcessingPlugin {
fn build(&self, app: &mut App) {
app.init_asset::<BlendSource>()
.init_asset::<ProcessedBlend>()
.register_asset_loader(BlendSourceLoader)
.register_asset_processor(BlendProcessor::new(BlendTransformer, BlendSaver))
.set_default_asset_processor::<BlendProcessor>("blend");
}
}
struct TempDir(PathBuf);
impl TempDir {
fn new() -> io::Result<Self> {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path =
std::env::temp_dir().join(format!("shinespark-blend-{}-{stamp}", std::process::id()));
fs::create_dir_all(&path)?;
Ok(Self(path))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn asset_root() -> PathBuf {
std::env::var_os("BEVY_ASSET_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("assets"))
}
fn locate_source(path: &Path) -> Option<PathBuf> {
let root = asset_root();
[root.join(path), PathBuf::from(path)]
.into_iter()
.find(|candidate| candidate.is_file())
}
fn physical_to_asset_path(path: &Path, root: &Path) -> Option<String> {
let canonical = fs::canonicalize(path).ok()?;
let root = fs::canonicalize(root).ok()?;
let relative = canonical.strip_prefix(root).ok()?;
Some(relative.to_string_lossy().replace('\\', "/"))
}
fn make_glb(
gltf_path: &Path,
temp: &TempDir,
model_asset_path: &Path,
asset_root: &Path,
) -> io::Result<Vec<u8>> {
let mut json: serde_json::Value = serde_json::from_slice(&fs::read(gltf_path)?)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let bin_name = json["buffers"][0]["uri"].as_str().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "glTF has no binary buffer URI")
})?;
let mut bin = fs::read(temp.path().join(bin_name))?;
let model_parent = model_asset_path.parent().unwrap_or_else(|| Path::new(""));
let image_count = json["images"].as_array().map_or(0, Vec::len);
if image_count > 0 && !json["bufferViews"].is_array() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"glTF bufferViews are not an array",
));
}
for index in 0..image_count {
let uri = json["images"][index]
.get("uri")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let Some(uri) = uri else { continue };
if uri.starts_with("data:") {
continue;
}
let uri_path = temp.path().join(uri);
if let Some(asset_path) = physical_to_asset_path(&uri_path, asset_root) {
json["images"][index]["uri"] =
serde_json::Value::String(relative_uri(model_parent, Path::new(&asset_path)));
} else {
let bytes = fs::read(&uri_path)?;
while bin.len() % 4 != 0 {
bin.push(0);
}
let offset = bin.len();
bin.extend_from_slice(&bytes);
let mime = mime_type(&uri_path);
let view = json["bufferViews"].as_array().unwrap().len();
json["bufferViews"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({
"buffer": 0,
"byteOffset": offset,
"byteLength": bytes.len(),
}));
json["images"][index].as_object_mut().unwrap().remove("uri");
json["images"][index]["bufferView"] = serde_json::Value::from(view);
json["images"][index]["mimeType"] = serde_json::Value::String(mime.to_owned());
}
}
json["buffers"][0]["byteLength"] = serde_json::Value::from(bin.len());
json["buffers"][0].as_object_mut().unwrap().remove("uri");
let mut json_bytes = serde_json::to_vec(&json).map_err(io::Error::other)?;
while json_bytes.len() % 4 != 0 {
json_bytes.push(b' ');
}
while bin.len() % 4 != 0 {
bin.push(0);
}
let length = 12 + 8 + json_bytes.len() + 8 + bin.len();
let mut glb = Vec::with_capacity(length);
glb.extend_from_slice(b"glTF");
glb.extend_from_slice(&2u32.to_le_bytes());
glb.extend_from_slice(&(length as u32).to_le_bytes());
glb.extend_from_slice(&(json_bytes.len() as u32).to_le_bytes());
glb.extend_from_slice(b"JSON");
glb.extend_from_slice(&json_bytes);
glb.extend_from_slice(&(bin.len() as u32).to_le_bytes());
glb.extend_from_slice(b"BIN\0");
glb.extend_from_slice(&bin);
Ok(glb)
}
fn relative_uri(from: &Path, to: &Path) -> String {
let from: Vec<_> = from.components().collect();
let to: Vec<_> = to.components().collect();
let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
let mut result = PathBuf::new();
for _ in common..from.len() {
result.push("..");
}
for component in &to[common..] {
result.push(component.as_os_str());
}
result.to_string_lossy().replace('\\', "/")
}
fn mime_type(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
_ => "image/png",
}
}
-80
View File
@@ -1,80 +0,0 @@
import bpy
import json
import os
import sys
def main():
output = sys.argv[sys.argv.index("--") + 1]
actions_path = sys.argv[sys.argv.index("--") + 2]
source_path = sys.argv[sys.argv.index("--") + 3]
loaded_path = os.path.realpath(bpy.data.filepath)
expected_path = os.path.realpath(source_path)
if loaded_path != expected_path:
raise RuntimeError(
f"Blender loaded {loaded_path!r} instead of {expected_path!r}"
)
if not bpy.data.objects or not bpy.data.meshes:
raise RuntimeError("Blender loaded an empty scene")
with open(actions_path) as file:
actions_to_keep = set(json.load(file))
for obj in bpy.data.objects:
if not obj.animation_data:
continue
for track in list(obj.animation_data.nla_tracks):
if track.name.startswith("[Action Stash]"):
obj.animation_data.nla_tracks.remove(track)
nla_tracks = [
track
for obj in bpy.data.objects
if obj.animation_data
for track in obj.animation_data.nla_tracks
]
use_nla_tracks = bool(nla_tracks)
# For blends without NLA tracks, use the actions referenced by the fighter
# manifest instead of sampling every source action.
if not use_nla_tracks and actions_to_keep:
from io_scene_gltf2 import GLTF2_filter_action
if not hasattr(bpy.types.Scene, "gltf_action_filter"):
bpy.types.Scene.gltf_action_filter = bpy.props.CollectionProperty(
type=GLTF2_filter_action
)
scene = bpy.context.scene
scene.gltf_action_filter.clear()
for action in bpy.data.actions:
item = scene.gltf_action_filter.add()
item.action = action
item.keep = action.name in actions_to_keep
bpy.ops.export_scene.gltf(
filepath=output,
export_format="GLTF_SEPARATE",
export_keep_originals=True,
export_extras=True,
export_animations=True,
export_skins=True,
export_morph=True,
export_yup=True,
export_def_bones=True,
export_action_filter=False,
export_animation_mode="NLA_TRACKS" if use_nla_tracks else "ACTIONS",
)
dependencies = []
for image in bpy.data.images:
if image.source not in {"FILE", "TILED"}:
continue
path = image.filepath_raw or image.filepath
if path and not image.packed_file:
dependencies.append(bpy.path.abspath(path))
with open(os.path.join(os.path.dirname(output), "export-manifest.json"), "w") as file:
json.dump({"dependencies": sorted(set(dependencies))}, file)
if __name__ == "__main__":
main()
+5 -9
View File
@@ -5,7 +5,7 @@ use bevy_ggrs::GgrsFrameTiming;
use crate::{ use crate::{
debug_tools::DebugSettings, debug_tools::DebugSettings,
fighter::{manifest::FighterManifest, motion::{sample_presentation_translation_with_snap, FighterPresentationSnap}}, fighter::{manifest::FighterManifest, motion::sample_presentation_translation},
stage::manifest::StageCameraProfile, stage::manifest::StageCameraProfile,
}; };
@@ -313,7 +313,6 @@ fn update_match_camera(
&crate::fighter::motion::FighterPreviousTranslation, &crate::fighter::motion::FighterPreviousTranslation,
&crate::fighter::Fighter, &crate::fighter::Fighter,
&crate::fighter::FighterFacingDirection, &crate::fighter::FighterFacingDirection,
&FighterPresentationSnap,
&mut FighterCameraExtents, &mut FighterCameraExtents,
), ),
With<crate::player::Player>, With<crate::player::Player>,
@@ -330,17 +329,16 @@ fn update_match_camera(
let subjects = fighters let subjects = fighters
.iter_mut() .iter_mut()
.map( .map(
|(translation, previous_translation, fighter, facing, presentation_snap, mut extents)| { |(translation, previous_translation, fighter, facing, mut extents)| {
let manifest = manifests let manifest = manifests
.get(&fighter.manifest) .get(&fighter.manifest)
.expect("Fighter manifest should be valid"); .expect("Fighter manifest should be valid");
fighter_subject_bounds( fighter_subject_bounds(
sample_presentation_translation_with_snap( sample_presentation_translation(
*previous_translation, *previous_translation,
*translation, *translation,
timing.overstep_fraction(), timing.overstep_fraction(),
debug_settings.motion_sampling, debug_settings.motion_sampling,
*presentation_snap,
), ),
manifest.camera, manifest.camera,
*facing, *facing,
@@ -467,7 +465,6 @@ fn debug_draw_camera(
&crate::fighter::motion::FighterPreviousTranslation, &crate::fighter::motion::FighterPreviousTranslation,
&crate::fighter::Fighter, &crate::fighter::Fighter,
&crate::fighter::FighterFacingDirection, &crate::fighter::FighterFacingDirection,
&FighterPresentationSnap,
&FighterCameraExtents, &FighterCameraExtents,
), ),
With<crate::player::Player>, With<crate::player::Player>,
@@ -489,16 +486,15 @@ fn debug_draw_camera(
Color::srgb(1.0, 0.5, 0.0), Color::srgb(1.0, 0.5, 0.0),
); );
draw_rectangle(&mut gizmos, camera.frame, Color::srgb(1.0, 1.0, 0.0)); draw_rectangle(&mut gizmos, camera.frame, Color::srgb(1.0, 1.0, 0.0));
for (translation, previous_translation, fighter, _facing, presentation_snap, extents) in &fighters { for (translation, previous_translation, fighter, _facing, extents) in &fighters {
let manifest = manifests let manifest = manifests
.get(&fighter.manifest) .get(&fighter.manifest)
.expect("Fighter manifest should be valid"); .expect("Fighter manifest should be valid");
let center = sample_presentation_translation_with_snap( let center = sample_presentation_translation(
*previous_translation, *previous_translation,
*translation, *translation,
timing.overstep_fraction(), timing.overstep_fraction(),
debug_settings.motion_sampling, debug_settings.motion_sampling,
*presentation_snap,
) + Vec2::Y * manifest.camera.vertical_origin_offset.to_num::<f32>(); ) + Vec2::Y * manifest.camera.vertical_origin_offset.to_num::<f32>();
draw_rectangle( draw_rectangle(
&mut gizmos, &mut gizmos,
+4 -49
View File
@@ -3,9 +3,11 @@
use std::{collections::HashMap, time::Duration}; use std::{collections::HashMap, time::Duration};
use bevy::{ use bevy::{
camera::{CameraOutputMode, visibility::RenderLayers}, input::InputSystems, prelude::*, render::render_resource::BlendState, settings::{ReflectSettingsGroup, SaveSettingsDeferred, SettingsGroup}, input::InputSystems,
prelude::*,
settings::{ReflectSettingsGroup, SaveSettingsDeferred, SettingsGroup},
}; };
use bevy_egui::{EguiContexts, EguiGlobalSettings, EguiPrimaryContextPass, PrimaryEguiContext, egui}; use bevy_egui::{EguiContexts, EguiPrimaryContextPass, egui};
use bevy_ggrs::{RollbackFrameRate, RunGgrsSystems}; use bevy_ggrs::{RollbackFrameRate, RunGgrsSystems};
use enum_cycling::EnumCycle; use enum_cycling::EnumCycle;
use enum_cycling_derive::EnumCycle; use enum_cycling_derive::EnumCycle;
@@ -109,9 +111,7 @@ impl FighterDebugRowKind {
enum DebugFlag { enum DebugFlag {
Network, Network,
Ecb, Ecb,
BlastZones,
StageCollision, StageCollision,
SpawnPositions,
AnimationBones, AnimationBones,
AttackHitboxes, AttackHitboxes,
Hurtboxes, Hurtboxes,
@@ -175,14 +175,6 @@ const WORLD_MENU: &[DebugMenuEntry] = &[
label: "Stage collision", label: "Stage collision",
flag: DebugFlag::StageCollision, flag: DebugFlag::StageCollision,
}, },
DebugMenuEntry::Toggle {
label: "Blast zones",
flag: DebugFlag::BlastZones,
},
DebugMenuEntry::Toggle {
label: "Spawn points",
flag: DebugFlag::SpawnPositions,
},
DebugMenuEntry::GameSpeed { DebugMenuEntry::GameSpeed {
label: "Game speed", label: "Game speed",
}, },
@@ -253,9 +245,7 @@ pub struct DebugSettings {
pub frame_stepping: bool, pub frame_stepping: bool,
pub network: bool, pub network: bool,
pub ecb: bool, pub ecb: bool,
pub blast_zones: bool,
pub stage_collision: bool, pub stage_collision: bool,
pub spawn_positions: bool,
pub animation_bones: bool, pub animation_bones: bool,
pub attack_hitboxes: bool, pub attack_hitboxes: bool,
pub hurtboxes: bool, pub hurtboxes: bool,
@@ -276,9 +266,7 @@ impl Default for DebugSettings {
frame_stepping: false, frame_stepping: false,
network: false, network: false,
ecb: false, ecb: false,
blast_zones: false,
stage_collision: false, stage_collision: false,
spawn_positions: false,
animation_bones: false, animation_bones: false,
attack_hitboxes: false, attack_hitboxes: false,
hurtboxes: false, hurtboxes: false,
@@ -342,8 +330,6 @@ impl DebugSettings {
match flag { match flag {
DebugFlag::Network => self.network, DebugFlag::Network => self.network,
DebugFlag::Ecb => self.ecb, DebugFlag::Ecb => self.ecb,
DebugFlag::BlastZones => self.blast_zones,
DebugFlag::SpawnPositions => self.spawn_positions,
DebugFlag::StageCollision => self.stage_collision, DebugFlag::StageCollision => self.stage_collision,
DebugFlag::AnimationBones => self.animation_bones, DebugFlag::AnimationBones => self.animation_bones,
DebugFlag::AttackHitboxes => self.attack_hitboxes, DebugFlag::AttackHitboxes => self.attack_hitboxes,
@@ -358,9 +344,7 @@ impl DebugSettings {
match flag { match flag {
DebugFlag::Network => self.network = !self.network, DebugFlag::Network => self.network = !self.network,
DebugFlag::Ecb => self.ecb = !self.ecb, DebugFlag::Ecb => self.ecb = !self.ecb,
DebugFlag::BlastZones => self.blast_zones = !self.blast_zones,
DebugFlag::StageCollision => self.stage_collision = !self.stage_collision, DebugFlag::StageCollision => self.stage_collision = !self.stage_collision,
DebugFlag::SpawnPositions => self.spawn_positions = !self.spawn_positions,
DebugFlag::AnimationBones => self.animation_bones = !self.animation_bones, DebugFlag::AnimationBones => self.animation_bones = !self.animation_bones,
DebugFlag::AttackHitboxes => self.attack_hitboxes = !self.attack_hitboxes, DebugFlag::AttackHitboxes => self.attack_hitboxes = !self.attack_hitboxes,
DebugFlag::Hurtboxes => self.hurtboxes = !self.hurtboxes, DebugFlag::Hurtboxes => self.hurtboxes = !self.hurtboxes,
@@ -413,7 +397,6 @@ impl Plugin for DebugToolsPlugin {
.before(RunGgrsSystems) .before(RunGgrsSystems)
.run_if(in_state(AppState::InMatch)), .run_if(in_state(AppState::InMatch)),
) )
.add_systems(Startup, spawn_egui_camera)
.add_systems(PreUpdate, restore_frame_step_time.after(RunGgrsSystems)) .add_systems(PreUpdate, restore_frame_step_time.after(RunGgrsSystems))
.add_systems( .add_systems(
Update, Update,
@@ -425,26 +408,6 @@ impl Plugin for DebugToolsPlugin {
} }
} }
pub fn spawn_egui_camera(mut commands: Commands, mut egui_global_settings: ResMut<EguiGlobalSettings>,) {
egui_global_settings.auto_create_primary_context = false;
commands.spawn((
// The `PrimaryEguiContext` component requires everything needed to render a primary context.
PrimaryEguiContext,
Camera2d,
// Setting RenderLayers to none makes sure we won't render anything apart from the UI.
RenderLayers::none(),
Camera {
order: 2,
output_mode: CameraOutputMode::Write {
blend_state: Some(BlendState::ALPHA_BLENDING),
clear_color: ClearColorConfig::None,
},
clear_color: ClearColorConfig::Custom(Color::NONE),
..default()
},
));
}
fn player_handles(players: &Query<&Player>) -> Vec<usize> { fn player_handles(players: &Query<&Player>) -> Vec<usize> {
let mut handles = players let mut handles = players
.iter() .iter()
@@ -902,14 +865,6 @@ pub fn stage_collision_enabled(settings: Res<DebugSettings>) -> bool {
settings.stage_collision settings.stage_collision
} }
pub fn blast_zones_enabled(settings: Res<DebugSettings>) -> bool {
settings.blast_zones
}
pub fn spawn_positions_enabled(settings: Res<DebugSettings>) -> bool {
settings.spawn_positions
}
pub fn animation_bones_enabled(settings: Res<DebugSettings>) -> bool { pub fn animation_bones_enabled(settings: Res<DebugSettings>) -> bool {
settings.animation_bones settings.animation_bones
} }
+9 -15
View File
@@ -2,11 +2,11 @@ use bevy::prelude::*;
use crate::{ use crate::{
camera::MatchCamera, fighter::{ camera::MatchCamera, fighter::{
FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterInvulnerabilityState, FighterPendingRespawn, FighterPreviousFacingDirection, FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity, animation::FighterAnimationFrame, attack::{ FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation, FighterVelocity, Grounded, animation::FighterAnimationFrame, attack::{
FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterPendingHits, FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterPendingHits,
FighterSolvedHurtboxes, FighterStaleMoveQueue, FighterSolvedHurtboxes, FighterStaleMoveQueue,
}, baked_animation::FighterBoneMatrices, state::FighterState, }, baked_animation::FighterBoneMatrices, state::FighterState,
}, input::FighterInput, match_runtime::MatchState, player::Player, stage::line::StageCollision, }, input::FighterInput, player::Player, stage::line::StageCollision,
}; };
#[derive(Resource, Default)] #[derive(Resource, Default)]
@@ -19,7 +19,6 @@ struct Checkpoint {
fighters: Vec<FighterCheckpoint>, fighters: Vec<FighterCheckpoint>,
stage_collision: StageCollision, stage_collision: StageCollision,
pending_hits: FighterPendingHits, pending_hits: FighterPendingHits,
match_state: MatchState,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -45,9 +44,7 @@ struct FighterCheckpoint {
hitboxes: FighterHitboxes, hitboxes: FighterHitboxes,
solved_hurtboxes: FighterSolvedHurtboxes, solved_hurtboxes: FighterSolvedHurtboxes,
bone_matrices: FighterBoneMatrices, bone_matrices: FighterBoneMatrices,
surface: FighterSurfaceState, grounded: bool,
pending_respawn: FighterPendingRespawn,
invulnerability_state: FighterInvulnerabilityState,
} }
pub(super) fn save_or_restore(world: &mut World) { pub(super) fn save_or_restore(world: &mut World) {
@@ -92,12 +89,10 @@ pub(super) fn save_or_restore(world: &mut World) {
checkpoint_fighters.push(checkpoint); checkpoint_fighters.push(checkpoint);
} }
let pending_hits = world.resource::<FighterPendingHits>().clone(); let pending_hits = world.resource::<FighterPendingHits>().clone();
let match_state = world.resource::<MatchState>().clone();
world.resource_mut::<DebugSaveState>().checkpoint = Some(Checkpoint { world.resource_mut::<DebugSaveState>().checkpoint = Some(Checkpoint {
fighters: checkpoint_fighters, fighters: checkpoint_fighters,
stage_collision, stage_collision,
pending_hits, pending_hits,
match_state,
}); });
info!("Saved debug game state"); info!("Saved debug game state");
return; return;
@@ -148,13 +143,14 @@ pub(super) fn save_or_restore(world: &mut World) {
entity.insert(saved.hitboxes.clone()); entity.insert(saved.hitboxes.clone());
entity.insert(saved.solved_hurtboxes.clone()); entity.insert(saved.solved_hurtboxes.clone());
entity.insert(saved.bone_matrices.clone()); entity.insert(saved.bone_matrices.clone());
entity.insert(saved.surface.clone()); if saved.grounded {
entity.insert(saved.pending_respawn.clone()); entity.insert(Grounded);
entity.insert(saved.invulnerability_state.clone()); } else {
entity.remove::<Grounded>();
}
} }
world.insert_resource(checkpoint.pending_hits.clone()); world.insert_resource(checkpoint.pending_hits.clone());
world.insert_resource(checkpoint.stage_collision); world.insert_resource(checkpoint.stage_collision);
world.insert_resource(checkpoint.match_state);
for mut match_camera in world.query::<&mut MatchCamera>().iter_mut(world) { for mut match_camera in world.query::<&mut MatchCamera>().iter_mut(world) {
match_camera.needs_snap = true; match_camera.needs_snap = true;
} }
@@ -185,9 +181,7 @@ fn capture_fighter(world: &World, entity: Entity) -> Option<FighterCheckpoint> {
hitboxes: fighter.get::<FighterHitboxes>()?.clone(), hitboxes: fighter.get::<FighterHitboxes>()?.clone(),
solved_hurtboxes: fighter.get::<FighterSolvedHurtboxes>()?.clone(), solved_hurtboxes: fighter.get::<FighterSolvedHurtboxes>()?.clone(),
bone_matrices: fighter.get::<FighterBoneMatrices>()?.clone(), bone_matrices: fighter.get::<FighterBoneMatrices>()?.clone(),
surface: fighter.get::<FighterSurfaceState>()?.clone(), grounded: fighter.contains::<Grounded>(),
pending_respawn: fighter.get::<FighterPendingRespawn>()?.clone(),
invulnerability_state: fighter.get::<FighterInvulnerabilityState>()?.clone(),
}) })
} }
-6
View File
@@ -84,11 +84,6 @@ pub enum AnimKind {
AirDodge, AirDodge,
Fall, Fall,
Turn, Turn,
CrouchEnter,
CrouchIdle,
CrouchExit,
PlatformPass,
FlinchGround, FlinchGround,
FlinchAir, FlinchAir,
@@ -98,5 +93,4 @@ pub enum AnimKind {
AttackUpTilt, AttackUpTilt,
AttackForwardTilt, AttackForwardTilt,
AttackDownTilt, AttackDownTilt,
AttackNeutralAir,
} }
+2 -1
View File
@@ -179,7 +179,7 @@ pub enum AttackKind {
impl AttackKind { impl AttackKind {
pub fn get_animation(&self) -> AnimKind { pub fn get_animation(&self) -> AnimKind {
match self { match self {
AttackKind::NeutralAir => AnimKind::AttackNeutralAir, AttackKind::NeutralAir => AnimKind::AttackJab1,
AttackKind::Jab => AnimKind::AttackJab1, AttackKind::Jab => AnimKind::AttackJab1,
AttackKind::UpTilt => AnimKind::AttackUpTilt, AttackKind::UpTilt => AnimKind::AttackUpTilt,
AttackKind::ForwardAir => AnimKind::AttackUpTilt, AttackKind::ForwardAir => AnimKind::AttackUpTilt,
@@ -605,6 +605,7 @@ impl Plugin for FighterAttackPlugin {
intersect_attacks_with_hurtboxes, intersect_attacks_with_hurtboxes,
) )
.chain() .chain()
.after(crate::fighter::baked_animation::update_fighter_bone_matrices)
.in_set(GameplaySet::Attacks), .in_set(GameplaySet::Attacks),
); );
} }
-3
View File
@@ -44,9 +44,6 @@ pub struct FighterAttributes {
pub air_dodge_duration: u32, pub air_dodge_duration: u32,
pub air_dodge_velocity: FGi32, pub air_dodge_velocity: FGi32,
pub air_dodge_decay: FGi32, pub air_dodge_decay: FGi32,
pub air_dodge_intangible_duration: u32,
pub air_dodge_intangible_start: u32,
} }
impl FighterAttributes { impl FighterAttributes {
+1 -4
View File
@@ -2,8 +2,8 @@ use super::{
BAKED_ANIMATION_FPS, BAKED_ANIMATION_FPS,
runtime::{BakedAnimationClip, BakedFighterAnimations}, runtime::{BakedAnimationClip, BakedFighterAnimations},
}; };
use crate::fighter::{animation::AnimKind, manifest::FighterManifest};
use crate::math::fixed_transform::FixedMat4; use crate::math::fixed_transform::FixedMat4;
use crate::fighter::{animation::AnimKind, manifest::FighterManifest};
use bevy::{ use bevy::{
asset::{ asset::{
AssetLoader, AssetPath, AsyncWriteExt, LoadContext, AssetLoader, AssetPath, AsyncWriteExt, LoadContext,
@@ -51,9 +51,6 @@ impl AssetLoader for FighterBakeSourceLoader {
.map_err(|error| io::Error::other(error.to_string()))?; .map_err(|error| io::Error::other(error.to_string()))?;
let manifest: FighterManifest = ron::de::from_bytes(&manifest_bytes) let manifest: FighterManifest = ron::de::from_bytes(&manifest_bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
// `read_asset_bytes` uses the processor's processed asset source. This
// deliberately allows `model_path` to point at a `.blend`: the blend
// processor has already converted it to a hybrid GLB by this point.
let model_bytes = load_context let model_bytes = load_context
.read_asset_bytes(&manifest.model_path) .read_asset_bytes(&manifest.model_path)
.await .await
+7 -144
View File
@@ -4,11 +4,11 @@ mod asset_baking;
mod runtime; mod runtime;
mod visual_binding; mod visual_binding;
pub use crate::math::fixed_transform::FixedMat4;
pub use asset_baking::{BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin}; pub use asset_baking::{BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin};
pub use crate::math::fixed_transform::FixedMat4;
pub use runtime::{ pub use runtime::{
BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices, BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices,
resample_fighter_bone_matrices, update_fighter_bone_matrices, update_fighter_bone_matrices,
}; };
pub const BAKED_ANIMATION_FPS: u32 = 60; pub const BAKED_ANIMATION_FPS: u32 = 60;
@@ -21,7 +21,6 @@ mod tests {
use asset_baking::{ use asset_baking::{
Channel, ChannelValues, bake_gltf_document, baked_frame_count, sample_vec3, Channel, ChannelValues, bake_gltf_document, baked_frame_count, sample_vec3,
}; };
use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
#[test] #[test]
@@ -30,7 +29,11 @@ mod tests {
"../../../assets/fighters/test/test.fighter.ron" "../../../assets/fighters/test/test.fighter.ron"
)) ))
.unwrap(); .unwrap();
let (document, buffers) = test_gltf(&manifest.animations); let (document, buffers, _) = gltf::import(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/fighters/test/test_fighter.gltf"
))
.unwrap();
let baked = bake_gltf_document(document, buffers, &manifest.animations).unwrap(); let baked = bake_gltf_document(document, buffers, &manifest.animations).unwrap();
assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS); assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS);
@@ -48,104 +51,6 @@ mod tests {
assert_eq!(baked.parents.len(), baked.bone_names.len()); assert_eq!(baked.parents.len(), baked.bone_names.len());
} }
/// Builds the smallest glTF document that still exercises the complete
/// animation-baking path. Keeping this fixture in memory avoids making
/// unit tests depend on the production Blender asset or Blender itself.
fn test_gltf(
animations: &HashMap<AnimKind, String>,
) -> (gltf::Document, Vec<gltf::buffer::Data>) {
let mut names: Vec<_> = animations.values().cloned().collect();
names.sort();
names.dedup();
let mut data = Vec::new();
let mut buffer_views = Vec::new();
let mut accessors = Vec::new();
let mut gltf_animations = Vec::new();
for name in names {
let duration: f32 = match name.as_str() {
"Idle_Loop_Game" => 2.0,
"Walk_Loop_IK_Game" | "Run_Game" => 0.533333361,
"Dash_Game" => 0.333333343,
"Jump_Land_Game" => 0.133333340,
"JumpB_Game" => 56.0 / 60.0,
_ => 0.0,
};
let input_offset = data.len();
data.extend_from_slice(&0.0_f32.to_le_bytes());
data.extend_from_slice(&duration.to_le_bytes());
let output_offset = data.len();
data.extend_from_slice(
&[0.0_f32, 0.0, 0.0]
.into_iter()
.flat_map(f32::to_le_bytes)
.collect::<Vec<_>>(),
);
data.extend_from_slice(
&[1.0_f32, 0.0, 0.0]
.into_iter()
.flat_map(f32::to_le_bytes)
.collect::<Vec<_>>(),
);
let input_view = buffer_views.len();
buffer_views.push(json!({
"buffer": 0,
"byteOffset": input_offset,
"byteLength": 8
}));
let output_view = buffer_views.len();
buffer_views.push(json!({
"buffer": 0,
"byteOffset": output_offset,
"byteLength": 24
}));
let input_accessor = accessors.len();
accessors.push(json!({
"bufferView": input_view,
"componentType": 5126,
"count": 2,
"type": "SCALAR",
"min": [0.0],
"max": [duration]
}));
let output_accessor = accessors.len();
accessors.push(json!({
"bufferView": output_view,
"componentType": 5126,
"count": 2,
"type": "VEC3"
}));
gltf_animations.push(json!({
"name": name,
"samplers": [{
"input": input_accessor,
"output": output_accessor,
"interpolation": "LINEAR"
}],
"channels": [{
"sampler": 0,
"target": { "node": 0, "path": "translation" }
}]
}));
}
let document = json!({
"asset": { "version": "2.0" },
"scene": 0,
"scenes": [{ "nodes": [0] }],
"nodes": [{ "name": "root", "children": [1] }, { "name": "hand" }],
"buffers": [{ "byteLength": data.len() }],
"bufferViews": buffer_views,
"accessors": accessors,
"animations": gltf_animations
});
let bytes = serde_json::to_vec(&document).unwrap();
let gltf = gltf::Gltf::from_slice(&bytes).unwrap();
(gltf.document, vec![gltf::buffer::Data(data)])
}
#[test] #[test]
fn pose_sampling_wraps_repeating_frames_and_clamps_finished_frames() { fn pose_sampling_wraps_repeating_frames_and_clamps_finished_frames() {
let first = FixedMat4::IDENTITY; let first = FixedMat4::IDENTITY;
@@ -219,7 +124,6 @@ mod tests {
second.translate(FGVec3::lit("0", "3", "0")); second.translate(FGVec3::lit("0", "3", "0"));
let mut matrices = FighterBoneMatrices::default(); let mut matrices = FighterBoneMatrices::default();
matrices.set_pose( matrices.set_pose(
AnimKind::Wait,
&["root".into(), "hand".into()], &["root".into(), "hand".into()],
&[None, Some(0)], &[None, Some(0)],
&[first, second], &[first, second],
@@ -240,7 +144,6 @@ mod tests {
hand.translate(FGVec3::lit("0", "3", "0")); hand.translate(FGVec3::lit("0", "3", "0"));
let mut matrices = FighterBoneMatrices::default(); let mut matrices = FighterBoneMatrices::default();
matrices.set_pose( matrices.set_pose(
AnimKind::Wait,
&["root".into(), "hand".into()], &["root".into(), "hand".into()],
&[None, Some(0)], &[None, Some(0)],
&[root, hand], &[root, hand],
@@ -261,45 +164,6 @@ mod tests {
); );
} }
#[test]
fn repeated_pose_updates_reuse_scratch_and_output_buffers() {
let mut matrices = FighterBoneMatrices::default();
let names = ["root".into(), "hand".into()];
let parents = [None, Some(0)];
let pose = [FixedMat4::IDENTITY, FixedMat4::IDENTITY];
matrices.set_pose(AnimKind::Wait, &names, &parents, &pose, FGi32::ONE);
let capacities = matrices.buffer_capacities();
matrices.set_pose(AnimKind::Wait, &names, &parents, &pose, FGi32::ONE);
assert_eq!(capacities, matrices.buffer_capacities());
}
#[test]
fn same_frame_resample_preserves_previous_gameplay_pose() {
let names = ["root".into()];
let parents = [None];
let mut pose_a = FixedMat4::IDENTITY;
pose_a.translate(FGVec3::lit("1", "0", "0"));
let mut pose_b = FixedMat4::IDENTITY;
pose_b.translate(FGVec3::lit("2", "0", "0"));
let mut snapped_pose = FixedMat4::IDENTITY;
snapped_pose.translate(FGVec3::lit("3", "0", "0"));
let mut matrices = FighterBoneMatrices::default();
matrices.set_pose(AnimKind::Wait, &names, &parents, &[pose_a], FGi32::ONE);
matrices.set_pose(AnimKind::Wait, &names, &parents, &[pose_b], FGi32::ONE);
matrices.resample_pose(
AnimKind::Landing,
&names,
&parents,
&[snapped_pose],
FGi32::ONE,
);
assert_eq!(matrices.get_index(0), Some((pose_a, snapped_pose)));
}
#[test] #[test]
fn model_scale_affects_gameplay_bones_without_changing_visual_locals() { fn model_scale_affects_gameplay_bones_without_changing_visual_locals() {
let mut root = FixedMat4::IDENTITY; let mut root = FixedMat4::IDENTITY;
@@ -308,7 +172,6 @@ mod tests {
hand.translate(FGVec3::lit("0", "2", "0")); hand.translate(FGVec3::lit("0", "2", "0"));
let mut matrices = FighterBoneMatrices::default(); let mut matrices = FighterBoneMatrices::default();
matrices.set_pose( matrices.set_pose(
AnimKind::Wait,
&["root".into(), "hand".into()], &["root".into(), "hand".into()],
&[None, Some(0)], &[None, Some(0)],
&[root, hand], &[root, hand],
+31 -151
View File
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::BAKED_ANIMATION_FPS; use super::{BAKED_ANIMATION_FPS};
use crate::{ use crate::{
fighter::{ fighter::{
@@ -12,8 +12,8 @@ use crate::{
manifest::FighterManifest, manifest::FighterManifest,
visual::FighterAnimations, visual::FighterAnimations,
}, },
math::fixed_transform::FixedMat4,
math::int::FGi32, math::int::FGi32,
math::fixed_transform::FixedMat4
}; };
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -70,31 +70,12 @@ pub struct FighterBoneMatrices {
previous_frame_matrices: Vec<FixedMat4>, previous_frame_matrices: Vec<FixedMat4>,
local_matrices: Vec<FixedMat4>, local_matrices: Vec<FixedMat4>,
previous_local_matrices: Vec<FixedMat4>, previous_local_matrices: Vec<FixedMat4>,
model_matrices: Vec<Option<FixedMat4>>,
presentation_kind: Option<AnimKind>,
} }
#[derive(Component, Clone, Debug, Default)] #[derive(Component, Clone, Debug, Default)]
pub struct FighterBoneIndexMap(pub HashMap<String, usize>); pub struct FighterBoneIndexMap(pub HashMap<String, usize>);
impl FighterBoneMatrices { impl FighterBoneMatrices {
#[cfg(test)]
pub(super) fn buffer_capacities(&self) -> [usize; 5] {
[
self.matrices.capacity(),
self.local_matrices.capacity(),
self.previous_frame_matrices.capacity(),
self.previous_local_matrices.capacity(),
self.model_matrices.capacity(),
]
}
pub fn get_by_idx(&self, index: usize) -> Option<(FixedMat4, FixedMat4)> {
Some((
*self.previous_frame_matrices.get(index)?,
*self.matrices.get(index)?,
))
}
pub fn get(&self, bone: &str) -> Option<(FixedMat4, FixedMat4)> { pub fn get(&self, bone: &str) -> Option<(FixedMat4, FixedMat4)> {
let index = self.bone_names.iter().position(|name| name == bone)?; let index = self.bone_names.iter().position(|name| name == bone)?;
Some(( Some((
@@ -139,32 +120,10 @@ impl FighterBoneMatrices {
} }
pub(super) fn set_pose( pub(super) fn set_pose(
&mut self, &mut self,
kind: AnimKind,
bone_names: &[String], bone_names: &[String],
parents: &[Option<usize>], parents: &[Option<usize>],
local: &[FixedMat4], local: &[FixedMat4],
model_scale: FGi32, model_scale: FGi32,
) {
self.sample_pose(kind, bone_names, parents, local, model_scale, true);
}
pub(super) fn resample_pose(
&mut self,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
local: &[FixedMat4],
model_scale: FGi32,
) {
self.sample_pose(kind, bone_names, parents, local, model_scale, false);
}
fn sample_pose(
&mut self,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
local: &[FixedMat4],
model_scale: FGi32,
advance_history: bool,
) { ) {
assert_eq!( assert_eq!(
bone_names.len(), bone_names.len(),
@@ -183,22 +142,13 @@ impl FighterBoneMatrices {
assert_eq!(self.bone_names, bone_names, "baked bone names changed"); assert_eq!(self.bone_names, bone_names, "baked bone names changed");
assert_eq!(self.parents, parents, "baked bone parents changed"); assert_eq!(self.parents, parents, "baked bone parents changed");
} }
self.matrices.reserve(local.len()); std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices);
self.previous_frame_matrices.reserve(local.len()); std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices);
self.local_matrices.reserve(local.len());
self.previous_local_matrices.reserve(local.len());
self.model_matrices.reserve(local.len());
if advance_history {
std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices);
std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices);
}
let animation_changed = self.presentation_kind != Some(kind);
self.local_matrices.clear(); self.local_matrices.clear();
self.local_matrices.extend_from_slice(local); self.local_matrices.extend_from_slice(local);
self.model_matrices.resize(local.len(), None); let mut model = vec![None; local.len()];
self.model_matrices.fill(None);
for index in 0..local.len() { for index in 0..local.len() {
resolve_model_transform(index, parents, local, &mut self.model_matrices) resolve_model_transform(index, parents, local, &mut model)
.expect("baked bone hierarchy should be valid"); .expect("baked bone hierarchy should be valid");
} }
let model_scale = FixedMat4 { let model_scale = FixedMat4 {
@@ -209,20 +159,10 @@ impl FighterBoneMatrices {
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
], ],
}; };
self.matrices.clear(); self.matrices = model
self.matrices.extend( .into_iter()
self.model_matrices.iter().map(|matrix| { .map(|matrix| model_scale.mul(matrix.expect("baked bone hierarchy should be valid")))
model_scale.mul(matrix.expect("baked bone hierarchy should be valid")) .collect();
}),
);
// A state transition is a discontinuity, not an animation frame to
// interpolate from the previous clip. Keep the presentation pose at
// the new clip's first/current frame immediately.
if animation_changed {
self.previous_local_matrices
.clone_from(&self.local_matrices);
}
self.presentation_kind = Some(kind);
} }
} }
@@ -238,94 +178,34 @@ pub fn update_fighter_bone_matrices(
manifests: Res<Assets<FighterManifest>>, manifests: Res<Assets<FighterManifest>>,
) { ) {
for (frame, animations, fighter, mut matrices, mut bone_indices) in &mut fighters { for (frame, animations, fighter, mut matrices, mut bone_indices) in &mut fighters {
sample_fighter_bone_matrices( let baked = baked_assets
frame, .get(&animations.baked)
animations, .expect("fighter baked animation asset should be loaded before the match starts");
fighter, assert_eq!(
&mut matrices, baked.frame_rate, BAKED_ANIMATION_FPS,
&mut bone_indices, "fighter baked animation rate does not match the simulation rate"
&baked_assets,
&manifests,
true,
); );
} let pose = baked
} .sample_pose(frame)
.expect("fighter animation frame should select a baked pose");
/// Resamples the current pose after collision-driven state transitions without let manifest = manifests
/// advancing the previous-frame pose used for swept gameplay volumes. .get(&fighter.manifest)
pub fn resample_fighter_bone_matrices( .expect("fighter manifest should be loaded before the match starts");
mut fighters: Query<(
&FighterAnimationFrame,
&FighterAnimations,
&Fighter,
&mut FighterBoneMatrices,
&mut FighterBoneIndexMap,
)>,
baked_assets: Res<Assets<BakedFighterAnimations>>,
manifests: Res<Assets<FighterManifest>>,
) {
for (frame, animations, fighter, mut matrices, mut bone_indices) in &mut fighters {
sample_fighter_bone_matrices(
frame,
animations,
fighter,
&mut matrices,
&mut bone_indices,
&baked_assets,
&manifests,
false,
);
}
}
fn sample_fighter_bone_matrices(
frame: &FighterAnimationFrame,
animations: &FighterAnimations,
fighter: &Fighter,
matrices: &mut FighterBoneMatrices,
bone_indices: &mut FighterBoneIndexMap,
baked_assets: &Assets<BakedFighterAnimations>,
manifests: &Assets<FighterManifest>,
advance_history: bool,
) {
let baked = baked_assets
.get(&animations.baked)
.expect("fighter baked animation asset should be loaded before the match starts");
assert_eq!(
baked.frame_rate, BAKED_ANIMATION_FPS,
"fighter baked animation rate does not match the simulation rate"
);
let pose = baked
.sample_pose(frame)
.expect("fighter animation frame should select a baked pose");
let manifest = manifests
.get(&fighter.manifest)
.expect("fighter manifest should be loaded before the match starts");
if advance_history {
matrices.set_pose( matrices.set_pose(
frame.kind,
&baked.bone_names, &baked.bone_names,
&baked.parents, &baked.parents,
pose, pose,
manifest.model_scale, manifest.model_scale,
); );
} else { if bone_indices.0.is_empty() {
matrices.resample_pose( bone_indices.0.extend(
frame.kind, baked
&baked.bone_names, .bone_names
&baked.parents, .iter()
pose, .enumerate()
manifest.model_scale, .map(|(index, name)| (name.clone(), index)),
); );
} }
if bone_indices.0.is_empty() {
bone_indices.0.extend(
baked
.bone_names
.iter()
.enumerate()
.map(|(index, name)| (name.clone(), index)),
);
} }
} }
-36
View File
@@ -1,36 +0,0 @@
//! Various interactions with the blast zones
use bevy::prelude::*;
use crate::{fighter::{FighterPendingRespawn, FighterTranslation, state::FighterState}, match_loading::MatchConfig, stage::manifest::{StageManifest, StageManifestRegistry}};
pub fn apply_blast_zones(
stage_registry: Res<StageManifestRegistry>,
stage_manifests: Res<Assets<StageManifest>>,
match_config: Res<MatchConfig>,
fighters: Query<(&FighterTranslation, &mut FighterPendingRespawn, &FighterState)>
) {
let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else {
return;
};
let Some(stage_manifest) = stage_manifests.get(&stage_manifest_handle) else {
return;
};
for (translation, mut pending_respawn, state) in fighters {
let is_off_the_left = translation.x < stage_manifest.blast_zone_top_left.x;
let is_off_the_right = translation.x > stage_manifest.blast_zone_bottom_right.x;
let is_off_the_top = translation.y > stage_manifest.blast_zone_top_left.y;
let is_off_the_bottom = translation.y < stage_manifest.blast_zone_bottom_right.y;
if is_off_the_left || is_off_the_bottom || is_off_the_right {
pending_respawn.0 = true;
} else if is_off_the_top {
let is_in_hitstun = matches!(state, FighterState::Hitstun(..));
if is_in_hitstun {
pending_respawn.0 = true;
}
}
}
}
+27 -177
View File
@@ -4,7 +4,7 @@ use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterTranslation}; use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterTranslation};
use crate::math::segment::FGSegment2d; use crate::math::segment::FGSegment2d;
use crate::math::vec::FGVec2; use crate::math::vec::FGVec2;
use crate::stage::line::{StageCollision, StageLineID, StagePolyType}; use crate::stage::line::{StageCollision, StageLineID};
/* /*
pub fn collide_fighter_with_scene( pub fn collide_fighter_with_scene(
fighters: Query<(&FighterECB, &mut FighterVelocity, &mut FighterTranslation)>, fighters: Query<(&FighterECB, &mut FighterVelocity, &mut FighterTranslation)>,
@@ -44,187 +44,37 @@ pub fn air_collide_with_stage(
prev_ecb: &FighterPreviousECB, prev_ecb: &FighterPreviousECB,
ecb: &FighterECB, ecb: &FighterECB,
stage_collision: &StageCollision, stage_collision: &StageCollision,
ignored_polys: &[usize],
can_ignore_soft_platforms: bool,
) -> Option<AirCollisionWithStageResult> { ) -> Option<AirCollisionWithStageResult> {
let ray_segment = FGSegment2d::new( let ray_segment = FGSegment2d::new(
prev_translation.0 + prev_ecb.get_bottom_point(), prev_translation.0 + prev_ecb.get_bottom_point(),
translation.0 + ecb.get_bottom_point(), translation.0 + ecb.get_bottom_point(),
); );
stage_collision let mut intersect_results = vec![];
.stage_polys
.iter() for (poly_idx, poly) in stage_collision.stage_polys.iter().enumerate() {
.enumerate() if let Some(intersect_result) = poly.intersect_ray(ray_segment) {
.filter(|(poly_idx, poly)| { intersect_results.push((
!ignored_polys.contains(poly_idx) AirCollisionWithStageResult {
&& !(can_ignore_soft_platforms && matches!(poly.poly_type, StagePolyType::Platform)) line_id: {
}) StageLineID {
.filter_map(|(poly_idx, poly)| { polygon: poly_idx,
poly.intersect_ray(ray_segment).map(|intersect_result| { segment: intersect_result.segment_idx,
( }
AirCollisionWithStageResult {
line_id: {
StageLineID {
polygon: poly_idx,
segment: intersect_result.segment_idx,
}
},
hit_position: intersect_result.position,
hit_normal: intersect_result.normal,
}, },
intersect_result hit_position: intersect_result.position,
.position hit_normal: intersect_result.normal,
.distance_squared(ray_segment.point2()), },
) intersect_result
}) .position
}) .distance_squared(ray_segment.point2()),
.max_by(|(_, a), (_, b)| a.cmp(b)) ));
.map(|(result, _)| result) }
} }
intersect_results.sort_by(|(_, a), (_, b)| a.cmp(b));
#[cfg(test)]
mod tests { intersect_results
use super::*; .iter()
use crate::fighter::ecb::{FighterECB, FighterPreviousECB}; .last()
use crate::stage::line::{StagePoly, StagePolyLineSegmentType, StagePolyType}; .map(|(val, _)| val.to_owned().clone())
fn horizontal_platform(y: &str) -> StagePoly {
StagePoly::build(
StagePolyType::Platform,
&[
(FGVec2::lit("-2", y), StagePolyLineSegmentType::Floor),
(FGVec2::lit("2", y), StagePolyLineSegmentType::Floor),
],
)
}
fn solid_floor() -> StagePoly {
StagePoly::build(
StagePolyType::Closed,
&[
(FGVec2::lit("-2", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("2", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("2", "-2"), StagePolyLineSegmentType::Wall),
(FGVec2::lit("-2", "-2"), StagePolyLineSegmentType::Wall),
],
)
}
#[test]
fn airborne_collision_skips_ignored_polygons() {
let stage_collision = StageCollision {
stage_polys: vec![horizontal_platform("0"), horizontal_platform("1")],
};
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "2"));
let translation = FighterTranslation(FGVec2::lit("0", "-1"));
let previous_ecb = FighterPreviousECB {
bottom: FGVec2::ZERO,
top: FGVec2::ZERO,
left: FGVec2::ZERO,
right: FGVec2::ZERO,
};
let ecb = FighterECB {
bottom: FGVec2::ZERO,
top: FGVec2::ZERO,
left: FGVec2::ZERO,
right: FGVec2::ZERO,
};
let collision = air_collide_with_stage(
&previous_translation,
&translation,
&previous_ecb,
&ecb,
&stage_collision,
&[1],
false,
)
.expect("the non-ignored platform should still be collidable");
assert_eq!(collision.line_id.polygon, 0);
assert_eq!(collision.hit_position, FGVec2::lit("0", "0"));
}
#[test]
fn airborne_collision_skips_soft_platforms_while_passing_down() {
let stage_collision = StageCollision {
stage_polys: vec![horizontal_platform("0")],
};
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "2"));
let translation = FighterTranslation(FGVec2::lit("0", "-1"));
let previous_ecb = FighterPreviousECB {
bottom: FGVec2::ZERO,
top: FGVec2::ZERO,
left: FGVec2::ZERO,
right: FGVec2::ZERO,
};
let ecb = FighterECB {
bottom: FGVec2::ZERO,
top: FGVec2::ZERO,
left: FGVec2::ZERO,
right: FGVec2::ZERO,
};
assert!(air_collide_with_stage(
&previous_translation,
&translation,
&previous_ecb,
&ecb,
&stage_collision,
&[],
true,
)
.is_none());
assert_eq!(
air_collide_with_stage(
&previous_translation,
&translation,
&previous_ecb,
&ecb,
&stage_collision,
&[],
false,
)
.expect("air dodges still collide with soft platforms")
.hit_position,
FGVec2::lit("0", "0")
);
}
#[test]
fn airborne_collision_still_hits_solid_floors_while_passing_down() {
let stage_collision = StageCollision {
stage_polys: vec![solid_floor()],
};
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "2"));
let translation = FighterTranslation(FGVec2::lit("0", "-1"));
let previous_ecb = FighterPreviousECB {
bottom: FGVec2::ZERO,
top: FGVec2::ZERO,
left: FGVec2::ZERO,
right: FGVec2::ZERO,
};
let ecb = FighterECB {
bottom: FGVec2::ZERO,
top: FGVec2::ZERO,
left: FGVec2::ZERO,
right: FGVec2::ZERO,
};
let collision = air_collide_with_stage(
&previous_translation,
&translation,
&previous_ecb,
&ecb,
&stage_collision,
&[],
true,
)
.expect("solid floors remain collidable while passing down");
assert_eq!(collision.line_id.polygon, 0);
assert_eq!(collision.hit_position, FGVec2::lit("0", "0"));
}
} }
+27 -59
View File
@@ -1,9 +1,10 @@
//! Fighter debug views: gizmos and the egui inspector window. //! Fighter debug views: gizmos and the egui inspector window.
use std::sync::OnceLock;
use bevy::color::palettes::css::{ORANGE, ORANGE_RED, WHITE}; use bevy::color::palettes::css::ORANGE;
use bevy::prelude::*; use bevy::{color::palettes::css::YELLOW, prelude::*};
use bevy_egui::{egui, EguiContexts}; use bevy_egui::{EguiContexts, egui};
use crate::debug_tools::{DebugSettings, FighterDebugRowKind}; use crate::debug_tools::{DebugSettings, FighterDebugRowKind};
use crate::fighter::animation::FighterAnimationFrame; use crate::fighter::animation::FighterAnimationFrame;
@@ -14,19 +15,18 @@ use crate::fighter::attack::{
use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices};
use crate::fighter::ecb::FighterPreviousECB; use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::errors::FighterErrors; use crate::fighter::errors::FighterErrors;
use crate::fighter::hurtbox::FixedAffineCapsule;
use crate::fighter::manifest::FighterManifest; use crate::fighter::manifest::FighterManifest;
use crate::fighter::state::{air, FighterState}; use crate::fighter::state::{FighterState, air};
use crate::fighter::visual::FighterAnimations; use crate::fighter::visual::FighterAnimations;
use crate::fighter::{ use crate::fighter::{
Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation,
FighterTranslation, FighterVelocity, FighterTranslation, FighterVelocity,
}; };
use crate::filled_gizmos::FilledGizmos;
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
use crate::input::FighterInput; use crate::input::FighterInput;
use crate::math::int::FGi32; use crate::math::int::FGi32;
use crate::math::vec::FGVec2; use crate::math::vec::FGVec2;
use crate::math::vec3::FGVec3;
use crate::player::Player; use crate::player::Player;
pub fn debug_draw_ecb( pub fn debug_draw_ecb(
@@ -37,27 +37,17 @@ pub fn debug_draw_ecb(
&FighterPreviousTranslation, &FighterPreviousTranslation,
)>, )>,
mut gizmos: Gizmos, mut gizmos: Gizmos,
mut filled_gizmos: FilledGizmos,
) { ) {
for (ecb, trf, ecb_prev, trf_prev) in ecbs { for (ecb, trf, ecb_prev, trf_prev) in ecbs {
gizmos.cross(
FGVec3::new(trf.0.x, trf.0.y, FGi32::ZERO).to_vec3(),
2.0,
bevy::color::palettes::css::BLUE,
);
let prev_transformed = ecb_prev.to_3d_lineloop(Transform::from_xyz(
trf_prev.x.to_num::<f32>(),
trf_prev.y.to_num(),
0.0,
));
filled_gizmos.polygon(&prev_transformed, ORANGE_RED);
let transformed = let transformed =
ecb.to_3d_lineloop(Transform::from_xyz(trf.x.to_num(), trf.y.to_num(), 0.0)); ecb.to_3d_lineloop(Transform::from_xyz(trf.x.to_num(), trf.y.to_num(), 0.0));
filled_gizmos.polygon(&transformed, ORANGE); gizmos.lineloop(transformed, YELLOW);
gizmos.lineloop(transformed, WHITE); let prev_transformed = ecb_prev.to_3d_lineloop(Transform::from_xyz(
trf_prev.x.to_num(),
trf_prev.y.to_num(),
-0.1,
));
gizmos.lineloop(prev_transformed, ORANGE);
} }
} }
pub fn fighter_debug( pub fn fighter_debug(
@@ -163,17 +153,10 @@ pub fn fighter_debug(
Ok(()) Ok(())
} }
pub fn animation_debug( pub fn animation_debug(query: Query<(&GlobalTransform, &FighterBoneMatrices)>, mut gizmos: Gizmos) {
query: Query<( for (global_transform, matrices) in query {
&FighterTranslation, let (_, rotation, translation) = global_transform.to_scale_rotation_translation();
&FighterFacingDirection, let marker_transform = Transform::from_translation(translation).with_rotation(rotation);
&FighterBoneMatrices,
)>,
mut gizmos: Gizmos,
) {
for (global_transform, facing_dir, matrices) in query {
let global_transform = global_transform.get_3d_transform(facing_dir);
let marker_transform = global_transform.to_mat4();
for (_, matrix) in matrices.iter() { for (_, matrix) in matrices.iter() {
let pos = marker_transform.transform_point(Vec3::new( let pos = marker_transform.transform_point(Vec3::new(
matrix.cols[3][0].to_num(), matrix.cols[3][0].to_num(),
@@ -186,23 +169,15 @@ pub fn animation_debug(
} }
} }
pub fn hurtbox_debug( pub fn hurtbox_debug(query: Query<&FighterSolvedHurtboxes>, mut gizmos: Gizmos) {
query: Query<&FighterSolvedHurtboxes>,
mut filled_gizmos: FilledGizmos,
) {
for hurtboxes in &query { for hurtboxes in &query {
for hurtbox in &hurtboxes.0 { for hurtbox in &hurtboxes.0 {
filled_gizmos.affine_capsule( draw_affine_capsule(&mut gizmos, hurtbox.capsule, YELLOW);
hurtbox.capsule.transform.to_mat4(),
hurtbox.capsule.radius.to_num(),
hurtbox.capsule.half_length.to_num(),
Color::srgba(1.0, 1.0, 0.0, 0.75),
);
} }
} }
} }
/*fn draw_affine_capsule( fn draw_affine_capsule(
gizmos: &mut Gizmos, gizmos: &mut Gizmos,
capsule: FixedAffineCapsule, capsule: FixedAffineCapsule,
color: impl Into<Color> + Copy, color: impl Into<Color> + Copy,
@@ -224,9 +199,9 @@ pub fn hurtbox_debug(
for &(start, end) in &wireframe.lines { for &(start, end) in &wireframe.lines {
gizmos.line(vertices[start], vertices[end], color); gizmos.line(vertices[start], vertices[end], color);
} }
}*/ }
/*struct CapsuleWireVertex { struct CapsuleWireVertex {
direction: Vec3, direction: Vec3,
center_sign: f32, center_sign: f32,
} }
@@ -234,9 +209,9 @@ pub fn hurtbox_debug(
struct CapsuleWireframe { struct CapsuleWireframe {
vertices: Vec<CapsuleWireVertex>, vertices: Vec<CapsuleWireVertex>,
lines: Vec<(usize, usize)>, lines: Vec<(usize, usize)>,
}*/ }
/*fn capsule_wireframe() -> &'static CapsuleWireframe { fn capsule_wireframe() -> &'static CapsuleWireframe {
const RESOLUTION: usize = 16; const RESOLUTION: usize = 16;
const HEMISPHERE_STEPS: usize = 4; const HEMISPHERE_STEPS: usize = 4;
static WIREFRAME: OnceLock<CapsuleWireframe> = OnceLock::new(); static WIREFRAME: OnceLock<CapsuleWireframe> = OnceLock::new();
@@ -317,7 +292,7 @@ struct CapsuleWireframe {
CapsuleWireframe { vertices, lines } CapsuleWireframe { vertices, lines }
}) })
}*/ }
#[cfg(test)] #[cfg(test)]
mod capsule_debug_tests { mod capsule_debug_tests {
@@ -337,7 +312,7 @@ mod capsule_debug_tests {
assert!(marker.abs_diff_eq(Vec3::new(3.0, 4.0, 3.0), 1e-5)); assert!(marker.abs_diff_eq(Vec3::new(3.0, 4.0, 3.0), 1e-5));
} }
/*#[test] #[test]
fn capsule_wireframe_caps_extend_in_opposite_directions() { fn capsule_wireframe_caps_extend_in_opposite_directions() {
let wireframe = capsule_wireframe(); let wireframe = capsule_wireframe();
let lower: Vec<_> = wireframe let lower: Vec<_> = wireframe
@@ -355,7 +330,7 @@ mod capsule_debug_tests {
assert!(upper.iter().all(|vertex| vertex.direction.y >= 0.0)); assert!(upper.iter().all(|vertex| vertex.direction.y >= 0.0));
assert!(lower.iter().any(|vertex| vertex.direction == Vec3::NEG_Y)); assert!(lower.iter().any(|vertex| vertex.direction == Vec3::NEG_Y));
assert!(upper.iter().any(|vertex| vertex.direction == Vec3::Y)); assert!(upper.iter().any(|vertex| vertex.direction == Vec3::Y));
}*/ }
} }
pub fn attack_debug( pub fn attack_debug(
@@ -367,7 +342,6 @@ pub fn attack_debug(
&FighterAnimationFrame, &FighterAnimationFrame,
)>, )>,
mut gizmos: Gizmos, mut gizmos: Gizmos,
mut filled_gizmos: FilledGizmos,
) { ) {
for (hitboxes, _matrices, _translation, _facing_direction, _frame) in query { for (hitboxes, _matrices, _translation, _facing_direction, _frame) in query {
if let Some(active_attack) = &hitboxes.0 { if let Some(active_attack) = &hitboxes.0 {
@@ -394,12 +368,6 @@ pub fn attack_debug(
capsule_draw_trf.to_isometry(), capsule_draw_trf.to_isometry(),
bevy::color::palettes::css::RED, bevy::color::palettes::css::RED,
); );
filled_gizmos.capsule(
capsule_draw_trf.to_isometry(),
solved_hitbox.capsule.radius.to_num(),
length * 0.5,
Color::srgba(1.0, 0.0, 0.0, 0.18),
);
gizmos.cross(start, 1.0, bevy::color::palettes::css::GREEN); gizmos.cross(start, 1.0, bevy::color::palettes::css::GREEN);
gizmos.cross(end, 1.0, bevy::color::palettes::css::GREEN); gizmos.cross(end, 1.0, bevy::color::palettes::css::GREEN);
+48 -100
View File
@@ -1,44 +1,28 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::math::{int::FGi32, vec::FGVec2};
fighter::{
FighterFacingDirection, FighterSurfaceState, FighterTranslation,
baked_animation::FighterBoneMatrices,
}, math::{int::FGi32, vec::FGVec2},
};
/// Environment collision box: the diamond used to resolve a fighter against /// Environment collision box: the diamond used to resolve a fighter against
/// the stage. Purely geometry — the collision response lives in /// the stage. Purely geometry — the collision response lives in
/// [`super::collision`]. /// [`super::collision`].
#[derive(Component, Debug, Clone, Copy, Hash, Default)] #[derive(Component, Debug, Clone, Copy, Hash)]
#[require(FighterPreviousECB)] #[require(FighterPreviousECB)]
pub struct FighterECB { pub struct FighterECB {
pub top: FGVec2, pub vertical_half: FGi32,
pub bottom: FGVec2, pub horizontal_half: FGi32,
pub left: FGVec2,
pub right: FGVec2,
}
#[derive(Component, Debug, Clone)]
pub struct FighterECBBoneIndices {
pub indices: Vec<usize>,
} }
#[derive(Component, Debug, Clone, Copy, Default, Hash)] #[derive(Component, Debug, Clone, Copy, Default, Hash)]
pub struct FighterPreviousECB { pub struct FighterPreviousECB {
pub top: FGVec2, pub vertical_half: FGi32,
pub bottom: FGVec2, pub horizontal_half: FGi32,
pub left: FGVec2,
pub right: FGVec2,
} }
impl From<FighterECB> for FighterPreviousECB { impl From<FighterECB> for FighterPreviousECB {
fn from(ecb: FighterECB) -> Self { fn from(ecb: FighterECB) -> Self {
Self { Self {
top: ecb.top, vertical_half: ecb.vertical_half,
bottom: ecb.bottom, horizontal_half: ecb.horizontal_half,
left: ecb.left,
right: ecb.right,
} }
} }
} }
@@ -51,85 +35,53 @@ pub fn snapshot_fighter_ecb(fighters: Query<(&FighterECB, &mut FighterPreviousEC
impl FighterECB { impl FighterECB {
pub fn to_3d_lineloop(&self, trf: Transform) -> [Vec3; 4] { pub fn to_3d_lineloop(&self, trf: Transform) -> [Vec3; 4] {
[self.top, self.right, self.bottom, self.left] let rel_up = trf.transform_point(Vec3::new(
.map(|point| trf.transform_point(Vec3::new(point.x.to_num(), point.y.to_num(), 0.0))) 0.0,
(self.vertical_half + self.vertical_half).to_num(),
0.0,
));
let rel_down = trf.transform_point(Vec3::new(0.0, 0.0, 0.0));
let rel_left = trf.transform_point(Vec3::new(
(-self.horizontal_half).to_num(),
self.vertical_half.to_num(),
0.0,
));
let rel_right = trf.transform_point(Vec3::new(
self.horizontal_half.to_num(),
self.vertical_half.to_num(),
0.0,
));
[rel_up, rel_right, rel_down, rel_left]
} }
pub fn get_bottom_point(&self) -> FGVec2 { pub fn get_bottom_point(&self) -> FGVec2 {
self.bottom FGVec2::new(FGi32::ZERO, FGi32::ZERO)
} }
} }
impl FighterPreviousECB { impl FighterPreviousECB {
pub fn to_3d_lineloop(&self, trf: Transform) -> [Vec3; 4] { pub fn to_3d_lineloop(&self, trf: Transform) -> [Vec3; 4] {
[self.top, self.right, self.bottom, self.left] let rel_up = trf.transform_point(Vec3::new(
.map(|point| trf.transform_point(Vec3::new(point.x.to_num(), point.y.to_num(), 0.0))) 0.0,
(self.vertical_half + self.vertical_half).to_num(),
0.0,
));
let rel_down = trf.transform_point(Vec3::new(0.0, 0.0, 0.0));
let rel_left = trf.transform_point(Vec3::new(
(-self.horizontal_half).to_num(),
self.vertical_half.to_num(),
0.0,
));
let rel_right = trf.transform_point(Vec3::new(
self.horizontal_half.to_num(),
self.vertical_half.to_num(),
0.0,
));
[rel_up, rel_right, rel_down, rel_left]
} }
pub fn get_bottom_point(&self) -> FGVec2 { pub fn get_bottom_point(&self) -> FGVec2 {
self.bottom FGVec2::new(FGi32::ZERO, FGi32::ZERO)
}
}
pub fn update_ecb(
query: Query<(
&mut FighterECB,
&FighterSurfaceState,
&FighterECBBoneIndices,
&FighterBoneMatrices,
&FighterTranslation,
&FighterFacingDirection,
)>,
) {
for (mut ecb, surface_state, bone_indices, bone_matrices, translation, facing_dir) in query {
let trf = translation.get_3d_transform(facing_dir);
let transformed_ecb_bones = bone_indices.indices.iter().filter_map(|idx| {
let Some((_, bone_matrix)) = bone_matrices.get_by_idx(*idx) else {
return None;
};
let pt = trf.transform_point(bone_matrix.get_translation());
Some(FGVec2::new(pt.x, pt.y) - translation.0)
}).collect::<Vec<_>>();
let max_x = transformed_ecb_bones.iter().max_by(|a, b| a.x.cmp(&b.x)).map(|final_val| final_val.x).unwrap_or(FGi32::lit("0.5"));
let min_x = transformed_ecb_bones.iter().min_by(|a, b| a.x.cmp(&b.x)).map(|final_val| final_val.x).unwrap_or(FGi32::lit("-0.5"));
let max_y = transformed_ecb_bones.iter().max_by(|a, b| a.y.cmp(&b.y)).map(|final_val| final_val.y).unwrap_or(FGi32::lit("0.5"));
let min_y = transformed_ecb_bones.iter().min_by(|a, b| a.y.cmp(&b.y)).map(|final_val| final_val.y).unwrap_or(FGi32::lit("-0.5"));
let midpoint_height = (max_y + min_y) / FGi32::lit("2.0");
let top = FGVec2::new(FGi32::ZERO, max_y);
let bottom = FGVec2::new(FGi32::ZERO, min_y);
let left = FGVec2::new(min_x, midpoint_height);
let right = FGVec2::new(max_x, midpoint_height);
let ecb_lock_frames_remaining = if let FighterSurfaceState::Airborne { ecb_lock_frames_remaining, .. } = surface_state {
*ecb_lock_frames_remaining
} else {
0
};
ecb.top = top;
if ecb_lock_frames_remaining == 0 {
ecb.bottom = bottom;
}
ecb.left = left;
ecb.right = right;
if surface_state.is_grounded() {
ecb.bottom = FGVec2::ZERO;
}
}
}
pub fn decrement_ecb_lockout(surface_states: Query<&mut FighterSurfaceState>) {
for mut state in surface_states {
state.tick_ecb_lock();
state.tick_ignored_polygons();
} }
} }
@@ -140,16 +92,12 @@ mod tests {
#[test] #[test]
fn previous_ecb_copies_current_dimensions() { fn previous_ecb_copies_current_dimensions() {
let current = FighterECB { let current = FighterECB {
bottom: FGVec2::lit("0.0", "-0.5"), vertical_half: FGi32::lit("0.5"),
top: FGVec2::lit("0.0", "0.5"), horizontal_half: FGi32::lit("0.25"),
left: FGVec2::lit("-0.5", "0.0"),
right: FGVec2::lit("0.5", "0.0"),
}; };
let previous = FighterPreviousECB::from(current); let previous = FighterPreviousECB::from(current);
assert_eq!(previous.top, current.top); assert_eq!(previous.vertical_half, current.vertical_half);
assert_eq!(previous.bottom, current.bottom); assert_eq!(previous.horizontal_half, current.horizontal_half);
assert_eq!(previous.left, current.left);
assert_eq!(previous.right, current.right);
} }
} }
-1
View File
@@ -65,7 +65,6 @@ pub struct FighterManifest {
pub animations: HashMap<AnimKind, String>, pub animations: HashMap<AnimKind, String>,
pub attack_scripts: HashMap<AttackKind, String>, pub attack_scripts: HashMap<AttackKind, String>,
pub hurtboxes: Vec<FighterHurtbox>, pub hurtboxes: Vec<FighterHurtbox>,
pub ecb_bones: Vec<String>,
} }
#[derive(Resource)] #[derive(Resource)]
+16 -56
View File
@@ -18,9 +18,7 @@ pub mod hurtbox;
pub mod manifest; pub mod manifest;
pub mod motion; pub mod motion;
pub mod state; pub mod state;
pub mod surface;
pub mod visual; pub mod visual;
pub mod blast_zones;
// The fighter components are re-exported so the rest of the crate can say // The fighter components are re-exported so the rest of the crate can say
// `fighter::FighterVelocity` without caring which file it lives in. Named // `fighter::FighterVelocity` without caring which file it lives in. Named
@@ -30,10 +28,9 @@ pub use attributes::FighterAttributes;
pub use ecb::FighterECB; pub use ecb::FighterECB;
pub use manifest::FighterId; pub use manifest::FighterId;
pub use motion::{ pub use motion::{
FighterPresentationSnap, FighterPreviousFacingDirection, FighterPreviousTranslation, FighterPreviousFacingDirection, FighterPreviousTranslation, FighterTranslation,
FighterTranslation, FighterVelocity, FighterVelocity, Grounded,
}; };
pub use surface::FighterSurfaceState;
use crate::{ use crate::{
AppState, fighter::{ AppState, fighter::{
@@ -41,8 +38,8 @@ use crate::{
AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage, AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage,
FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes, FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes,
FighterStaleMoveQueue, FighterStaleMoveQueue,
}, baked_animation::{FighterBoneIndexMap, FighterBoneMatrices}, ecb::FighterECBBoneIndices, manifest::FighterManifest, state::{FighterState, air::AirborneStateCommon, fall::FallState}, visual::{FighterAnimations, FighterInvulnerabilityVisualState}, }, baked_animation::{FighterBoneIndexMap, FighterBoneMatrices}, manifest::FighterManifest, state::{FighterState, air::AirborneStateCommon, fall::FallState}, visual::FighterAnimations,
}, game_settings::GameSettings, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, stage::line::StageCollision, }, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, stage::line::StageCollision,
}; };
use state::state_interrupt_system; use state::state_interrupt_system;
@@ -61,14 +58,14 @@ pub enum FighterFacingDirection {
pub enum FighterHitstopShakeKind { pub enum FighterHitstopShakeKind {
#[default] #[default]
Horizontal, Horizontal,
Vertical, Vertical
} }
#[derive(Component, Copy, Clone, Hash, Debug, PartialEq, Reflect, Default)] #[derive(Component, Copy, Clone, Hash, Debug, PartialEq, Reflect, Default)]
pub enum FighterHitstopShakeRole { pub enum FighterHitstopShakeRole {
#[default] #[default]
Attacker, Attacker,
Receiver, Receiver
} }
#[derive(Component, Default, Copy, Clone, Hash)] #[derive(Component, Default, Copy, Clone, Hash)]
@@ -76,7 +73,7 @@ pub struct FighterHitstopShake {
pub remaining_frames: u32, pub remaining_frames: u32,
pub total_frames: u32, pub total_frames: u32,
pub kind: FighterHitstopShakeKind, pub kind: FighterHitstopShakeKind,
pub role: FighterHitstopShakeRole, pub role: FighterHitstopShakeRole
} }
#[derive(Component, Default, Copy, Clone, Hash)] #[derive(Component, Default, Copy, Clone, Hash)]
@@ -85,14 +82,6 @@ pub struct FighterHitstop {
pub remaining_frames: u32, pub remaining_frames: u32,
} }
#[derive(Component, Default, Copy, Clone, Hash, PartialEq, Eq)]
pub enum FighterInvulnerabilityState {
#[default]
Vulnerable,
Invulnerable,
Intangible
}
impl FighterFacingDirection { impl FighterFacingDirection {
fn reverse(&self) -> Self { fn reverse(&self) -> Self {
match self { match self {
@@ -137,20 +126,14 @@ impl FighterFacingDirection {
#[require(Transform)] #[require(Transform)]
pub struct FighterVisual; pub struct FighterVisual;
#[derive(Component, DerefMut, Deref, Debug, Default, Hash, Clone, Copy)]
pub struct FighterPendingRespawn(bool);
#[derive(Component)] #[derive(Component)]
#[require( #[require(
FighterStaleMoveQueue, FighterStaleMoveQueue,
FighterDamage, FighterDamage,
FighterPendingHitReaction, FighterPendingHitReaction,
FighterPendingRespawn,
FighterHitstop, FighterHitstop,
FighterKnockbackVelocity, FighterKnockbackVelocity,
FighterTranslation, FighterTranslation,
FighterInvulnerabilityState,
FighterInvulnerabilityVisualState,
)] )]
pub struct Fighter { pub struct Fighter {
pub manifest: Handle<FighterManifest>, pub manifest: Handle<FighterManifest>,
@@ -163,10 +146,6 @@ impl Plugin for FighterPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_plugins(FighterAttackPlugin) app.add_plugins(FighterAttackPlugin)
// Every state gets a chance to interrupt itself, in priority order. // Every state gets a chance to interrupt itself, in priority order.
.add_systems(
GgrsSchedule,
motion::clear_fighter_presentation_snaps.in_set(GameplaySet::Input),
)
.add_systems( .add_systems(
GgrsSchedule, GgrsSchedule,
( (
@@ -191,15 +170,10 @@ impl Plugin for FighterPlugin {
)*/ )*/
.add_systems( .add_systems(
GgrsSchedule, GgrsSchedule,
( state::state_collision_interrupt_system.in_set(GameplaySet::Collision),
(blast_zones::apply_blast_zones, ecb::update_ecb, ecb::decrement_ecb_lockout, state::state_collision_interrupt_system).chain(),
baked_animation::resample_fighter_bone_matrices,
)
.chain()
.in_set(GameplaySet::Collision),
) )
.add_systems( .add_systems(
PostUpdate, Update,
( (
motion::apply_fighter_translation_to_visuals, motion::apply_fighter_translation_to_visuals,
animation::apply_animation, animation::apply_animation,
@@ -222,25 +196,19 @@ impl Plugin for FighterPlugin {
.checksum_component_with_hash::<FighterTranslation>() .checksum_component_with_hash::<FighterTranslation>()
.rollback_component_with_copy::<FighterPreviousTranslation>() .rollback_component_with_copy::<FighterPreviousTranslation>()
.checksum_component_with_hash::<FighterPreviousTranslation>() .checksum_component_with_hash::<FighterPreviousTranslation>()
.rollback_component_with_copy::<FighterPresentationSnap>()
.checksum_component_with_hash::<FighterPresentationSnap>()
.rollback_component_with_copy::<FighterPreviousFacingDirection>() .rollback_component_with_copy::<FighterPreviousFacingDirection>()
.checksum_component_with_hash::<FighterPreviousFacingDirection>() .checksum_component_with_hash::<FighterPreviousFacingDirection>()
.rollback_component_with_copy::<animation::FighterAnimationFrame>() .rollback_component_with_copy::<animation::FighterAnimationFrame>()
.checksum_component_with_hash::<animation::FighterAnimationFrame>() .checksum_component_with_hash::<animation::FighterAnimationFrame>()
.rollback_component_with_clone::<FighterSurfaceState>() .rollback_component_with_copy::<Grounded>()
.checksum_component_with_hash::<FighterSurfaceState>() .checksum_component_with_hash::<Grounded>()
.rollback_component_with_copy::<FighterFacingDirection>() .rollback_component_with_copy::<FighterFacingDirection>()
.rollback_component_with_copy::<FighterDamage>() .rollback_component_with_copy::<FighterDamage>()
.checksum_component_with_hash::<FighterDamage>() .checksum_component_with_hash::<FighterDamage>()
.rollback_component_with_clone::<FighterPendingHitReaction>() .rollback_component_with_clone::<FighterPendingHitReaction>()
.checksum_component_with_hash::<FighterPendingHitReaction>() .checksum_component_with_hash::<FighterPendingHitReaction>()
.rollback_component_with_copy::<FighterPendingRespawn>()
.checksum_component_with_hash::<FighterPendingRespawn>()
.rollback_component_with_copy::<FighterHitstop>() .rollback_component_with_copy::<FighterHitstop>()
.checksum_component_with_hash::<FighterHitstop>() .checksum_component_with_hash::<FighterHitstop>()
.rollback_component_with_copy::<FighterInvulnerabilityState>()
.checksum_component_with_hash::<FighterInvulnerabilityState>()
.rollback_component_with_copy::<FighterKnockbackVelocity>() .rollback_component_with_copy::<FighterKnockbackVelocity>()
.checksum_component_with_hash::<FighterKnockbackVelocity>() .checksum_component_with_hash::<FighterKnockbackVelocity>()
.rollback_resource_with_reflect::<StageCollision>() .rollback_resource_with_reflect::<StageCollision>()
@@ -268,9 +236,6 @@ impl Plugin for FighterPlugin {
.run_if(crate::debug_tools::attack_hitboxes_enabled) .run_if(crate::debug_tools::attack_hitboxes_enabled)
.run_if(in_state(AppState::InMatch)), .run_if(in_state(AppState::InMatch)),
) )
.add_systems(FixedPostUpdate,
visual::update_status_colors.run_if(resource_exists::<GameSettings>)
)
.add_systems( .add_systems(
FixedPostUpdate, FixedPostUpdate,
debug::hurtbox_debug.run_if(crate::debug_tools::hurtboxes_enabled), debug::hurtbox_debug.run_if(crate::debug_tools::hurtboxes_enabled),
@@ -294,7 +259,7 @@ pub struct FighterActiveAttack {
pub active_hitboxes: Vec<usize>, pub active_hitboxes: Vec<usize>,
pub solved_hitboxes: Vec<attack::FighterSolvedHitbox>, pub solved_hitboxes: Vec<attack::FighterSolvedHitbox>,
pub hit_fighters: Vec<Entity>, pub hit_fighters: Vec<Entity>,
pub stalled: bool, pub stalled: bool
} }
#[derive(Component, Clone, Debug, Default, Hash)] #[derive(Component, Clone, Debug, Default, Hash)]
@@ -303,39 +268,34 @@ pub struct FighterHitboxes(pub Option<FighterActiveAttack>);
pub fn spawn_fighter( pub fn spawn_fighter(
commands: &mut Commands, commands: &mut Commands,
player_handle: usize, player_handle: usize,
controller_port: usize,
spawn_position: FGVec2, spawn_position: FGVec2,
manifest: Handle<FighterManifest>, manifest: Handle<FighterManifest>,
model_scale: FGi32, model_scale: FGi32,
animations: FighterAnimations, animations: FighterAnimations,
attack_scripts: FighterAttackScriptAssets, attack_scripts: FighterAttackScriptAssets,
visual_root: WorldAssetRoot, visual_root: WorldAssetRoot,
ecb_bone_indices: Vec<usize>,
) -> Entity { ) -> Entity {
commands commands
.spawn(( .spawn((
Name(format!("Fighter {player_handle}").into()), Name(format!("Fighter {player_handle}").into()),
Player { Player {
handle: player_handle, handle: player_handle,
controller_port,
}, },
Fighter { manifest: manifest }, Fighter { manifest: manifest },
FighterFacingDirection::Right, FighterFacingDirection::Right,
FighterPreviousFacingDirection(FighterFacingDirection::Right), FighterPreviousFacingDirection(FighterFacingDirection::Right),
FighterSurfaceState::default(),
Transform::from_scale(Vec3::splat(model_scale.to_num())), Transform::from_scale(Vec3::splat(model_scale.to_num())),
( (
FighterECB::default(), FighterECB {
vertical_half: FGi32::lit("5.0"),
horizontal_half: FGi32::lit("2.5"),
},
FighterHitboxes::default(), FighterHitboxes::default(),
FighterECBBoneIndices {
indices: ecb_bone_indices
}
), ),
( (
FighterVelocity::default(), FighterVelocity::default(),
FighterTranslation(spawn_position), FighterTranslation(spawn_position),
FighterPreviousTranslation(spawn_position), FighterPreviousTranslation(spawn_position),
FighterPresentationSnap::default(),
), ),
visual_root, visual_root,
animations, animations,
+17 -62
View File
@@ -5,27 +5,25 @@ use bevy::prelude::*;
use bevy_ggrs::GgrsFrameTiming; use bevy_ggrs::GgrsFrameTiming;
use crate::debug_tools::PresentationMotionMode; use crate::debug_tools::PresentationMotionMode;
use crate::fighter::FighterHitstopShake; use crate::fighter::{FighterHitstopShake};
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
use crate::{ use crate::{
fighter::{FighterFacingDirection, baked_animation::FixedMat4}, fighter::{FighterECB, FighterFacingDirection, baked_animation::FixedMat4},
math::{int::FGi32, vec::FGVec2, vec3::FGVec3}, math::{int::FGi32, vec::FGVec2, vec3::FGVec3},
}; };
#[derive(Component, Clone, Copy, Hash)] #[derive(Component, Clone, Copy, Hash)]
pub struct FighterPreviousFacingDirection(pub FighterFacingDirection); pub struct FighterPreviousFacingDirection(pub FighterFacingDirection);
#[derive(Component, Clone, Copy, Hash)]
pub struct Grounded;
#[derive(Component, Deref, DerefMut, Default, Debug, Clone, Copy, Hash)] #[derive(Component, Deref, DerefMut, Default, Debug, Clone, Copy, Hash)]
pub struct FighterVelocity(pub FGVec2); pub struct FighterVelocity(pub FGVec2);
#[derive(Component, Deref, DerefMut, Default, Clone, Copy, Hash)] #[derive(Component, Deref, DerefMut, Default, Clone, Copy, Hash)]
pub struct FighterPreviousTranslation(pub FGVec2); pub struct FighterPreviousTranslation(pub FGVec2);
/// Marks a simulation frame whose position changed discontinuously for
/// presentation purposes (for example, a landing correction).
#[derive(Component, Default, Clone, Copy, Hash, Debug, PartialEq, Eq)]
pub struct FighterPresentationSnap(pub bool);
#[derive(Component, Deref, DerefMut, Default, Clone, Copy, Hash)] #[derive(Component, Deref, DerefMut, Default, Clone, Copy, Hash)]
#[require(FighterPreviousTranslation)] #[require(FighterPreviousTranslation)]
pub struct FighterTranslation(pub FGVec2); pub struct FighterTranslation(pub FGVec2);
@@ -57,31 +55,11 @@ pub fn sample_presentation_translation(
} }
} }
pub fn sample_presentation_translation_with_snap(
previous: FighterPreviousTranslation,
current: FighterTranslation,
overstep_fraction: f32,
mode: PresentationMotionMode,
snapped: FighterPresentationSnap,
) -> Vec2 {
if snapped.0 {
Vec2::new(current.x.to_num(), current.y.to_num())
} else {
sample_presentation_translation(previous, current, overstep_fraction, mode)
}
}
pub fn clear_fighter_presentation_snaps(mut fighters: Query<&mut FighterPresentationSnap>) {
for mut snap in &mut fighters {
snap.0 = false;
}
}
pub fn apply_fighter_translation_to_visuals( pub fn apply_fighter_translation_to_visuals(
query: Query<( query: Query<(
&FighterTranslation, &FighterTranslation,
&FighterPreviousTranslation, &FighterPreviousTranslation,
&FighterPresentationSnap, &FighterECB,
&FighterHitstopShake, &FighterHitstopShake,
&mut Transform, &mut Transform,
)>, )>,
@@ -90,43 +68,33 @@ pub fn apply_fighter_translation_to_visuals(
game_settings: Res<GameSettings>, game_settings: Res<GameSettings>,
) { ) {
let overstep_fraction = timing.overstep_fraction(); let overstep_fraction = timing.overstep_fraction();
for (translation, previous_translation, presentation_snap, hitstop_shake, mut transform) in query { for (translation, previous_translation, ecb, hitstop_shake, mut transform) in query {
let translation = sample_presentation_translation_with_snap( let translation = sample_presentation_translation(
*previous_translation, *previous_translation,
*translation, *translation,
overstep_fraction, overstep_fraction,
debug_settings.motion_sampling, debug_settings.motion_sampling,
*presentation_snap,
); );
let bottom = ecb.get_bottom_point();
transform.translation = Vec3::new( transform.translation = Vec3::new(
translation.x, translation.x + bottom.x.to_num::<f32>(),
translation.y, translation.y + bottom.y.to_num::<f32>(),
0.0, 0.0,
); );
if hitstop_shake.remaining_frames > 0 if hitstop_shake.remaining_frames > 0 && hitstop_shake.total_frames > 0 && !game_settings.fighter_common.visual_shake_table.is_empty() {
&& hitstop_shake.total_frames > 0
&& !game_settings.fighter_common.visual_shake_table.is_empty()
{
let total = hitstop_shake.total_frames as f32; let total = hitstop_shake.total_frames as f32;
let curr = hitstop_shake.remaining_frames as f32; let curr = hitstop_shake.remaining_frames as f32;
let elapsed = 1.0 - curr / total; let elapsed = 1.0 - curr / total;
let access_index = (hitstop_shake.remaining_frames as usize)
% game_settings.fighter_common.visual_shake_table.len(); let access_index = (hitstop_shake.remaining_frames as usize) % game_settings.fighter_common.visual_shake_table.len();
let shake_multiplier = match hitstop_shake.role { let shake_multiplier = match hitstop_shake.role {
super::FighterHitstopShakeRole::Attacker => { super::FighterHitstopShakeRole::Attacker => game_settings.fighter_common.attacker_visual_shake_multiplier,
game_settings
.fighter_common
.attacker_visual_shake_multiplier
}
super::FighterHitstopShakeRole::Receiver => 1.0, super::FighterHitstopShakeRole::Receiver => 1.0,
}; };
let shake_amount: f32 = let shake_amount: f32 = 1.0 - EaseFunction::QuinticIn.sample(elapsed).unwrap_or_default();
1.0 - EaseFunction::QuinticIn.sample(elapsed).unwrap_or_default(); let shake_amount = game_settings.fighter_common.visual_shake_table[access_index] * shake_amount * shake_multiplier;
let shake_amount = game_settings.fighter_common.visual_shake_table[access_index]
* shake_amount
* shake_multiplier;
let shake_offset = match hitstop_shake.kind { let shake_offset = match hitstop_shake.kind {
super::FighterHitstopShakeKind::Horizontal => Vec3::new(shake_amount, 0.0, 0.0), super::FighterHitstopShakeKind::Horizontal => Vec3::new(shake_amount, 0.0, 0.0),
@@ -196,19 +164,6 @@ mod tests {
assert_eq!(position, Vec2::new(7.0, 14.0)); assert_eq!(position, Vec2::new(7.0, 14.0));
} }
#[test]
fn presentation_translation_uses_current_sample_after_snap() {
let position = sample_presentation_translation_with_snap(
FighterPreviousTranslation(FGVec2::new(FGi32::lit("2"), FGi32::lit("4"))),
FighterTranslation(FGVec2::new(FGi32::lit("6"), FGi32::lit("12"))),
0.25,
PresentationMotionMode::Extrapolation,
FighterPresentationSnap(true),
);
assert_eq!(position, Vec2::new(6.0, 12.0));
}
#[test] #[test]
fn snapshot_fighter_motion_copies_current_world_state() { fn snapshot_fighter_motion_copies_current_world_state() {
let mut world = World::new(); let mut world = World::new();
+15 -43
View File
@@ -1,6 +1,6 @@
use crate::fighter::state::jump::{JumpState, JumpType}; use crate::fighter::state::ground::GroundedStateCommon;
use crate::fighter::state::land::{LandingState, LandingType}; use crate::fighter::state::land::{LandingState, LandingType};
use crate::fighter::state::{CollisionContext, FighterState, InterruptContext, air_attack, air_dodge}; use crate::fighter::state::{CollisionContext, FighterState};
use crate::fighter::*; use crate::fighter::*;
use crate::input::FighterCommands; use crate::input::FighterCommands;
@@ -30,7 +30,7 @@ pub fn process_fast_fall(
pub fn integrate_gravity( pub fn integrate_gravity(
velocity: &mut FighterVelocity, velocity: &mut FighterVelocity,
attributes: &FighterAttributes, attributes: &FighterAttributes,
fast_fall: bool, fast_fall: bool
) { ) {
velocity.y -= attributes.gravity; velocity.y -= attributes.gravity;
@@ -94,51 +94,25 @@ pub fn update_standard_motion(
ctx: &mut crate::fighter::state::UpdateContext, ctx: &mut crate::fighter::state::UpdateContext,
airborne_common: &mut AirborneStateCommon, airborne_common: &mut AirborneStateCommon,
) { ) {
process_fast_fall( process_fast_fall(airborne_common, ctx.input, &mut ctx.velocity, ctx.fighter_attributes);
airborne_common, integrate_gravity(ctx.velocity, ctx.fighter_attributes, airborne_common.is_fast_falling);
ctx.input,
&mut ctx.velocity,
ctx.fighter_attributes,
);
integrate_gravity(
ctx.velocity,
ctx.fighter_attributes,
airborne_common.is_fast_falling,
);
apply_air_drift(ctx.velocity, ctx.fighter_attributes, ctx.input); apply_air_drift(ctx.velocity, ctx.fighter_attributes, ctx.input);
apply_air_motion( apply_air_motion(ctx.velocity, ctx.translation, ctx.prev_translation);
ctx.velocity,
ctx.translation,
ctx.prev_translation,
);
}
pub fn air_interrupts_common(ctx: &mut InterruptContext, airborne_state_common: &AirborneStateCommon) -> Option<FighterState> {
if ctx.input.has_command(FighterCommands::Jump) {
Some(FighterState::Jump(JumpState {
jump_type: JumpType::DoubleJump,
airborne_state_common: AirborneStateCommon::default(),
}))
} else {
air_dodge::check_input(ctx).or_else(|| air_attack::check_interrupt(ctx, airborne_state_common))
}
} }
pub fn collision_interrupt( pub fn collision_interrupt(
ctx: &mut CollisionContext, ctx: &mut CollisionContext,
landing_type: LandingType, landing_type: LandingType,
can_ignore_soft_platforms: bool,
) -> Option<FighterState> { ) -> Option<FighterState> {
let res = ctx.air_collide_with_stage(can_ignore_soft_platforms)?; let res = ctx.air_collide_with_stage()?;
let diff = res.hit_position - ctx.translation.0; ctx.translation.0 = res.hit_position - ctx.ecb.get_bottom_point();
ctx.ecb.bottom -= diff; let grounded_common = GroundedStateCommon {
ctx.ecb.top -= diff; current_line_id: res.line_id,
ctx.ecb.left -= diff; };
ctx.ecb.right -= diff; Some(FighterState::Land(LandingState::create(
ctx.translation.0 = res.hit_position; grounded_common,
ctx.presentation_snap.0 = true; landing_type,
ctx.surface.land(res.line_id); )))
Some(FighterState::Land(LandingState::create(landing_type)))
} }
#[cfg(test)] #[cfg(test)]
@@ -181,8 +155,6 @@ mod tests {
air_dodge_duration: 0, air_dodge_duration: 0,
air_dodge_velocity: FGi32::ZERO, air_dodge_velocity: FGi32::ZERO,
air_dodge_decay: FGi32::ZERO, air_dodge_decay: FGi32::ZERO,
air_dodge_intangible_duration: 29,
air_dodge_intangible_start: 4,
} }
} }
-124
View File
@@ -1,124 +0,0 @@
use bevy::log::error;
use crate::fighter::{
FighterId, attack::{AttackKind, FighterAttackScriptAssets, update_active_hitbox_list}, state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, fall::FallState, wait::WaitState,
},
};
#[derive(Clone, Debug, Hash)]
pub struct AirAttackState {
pub attack_kind: AttackKind,
pub airborne_state_common: AirborneStateCommon
}
impl FighterStateImpl for AirAttackState {
const NAME: &'static str = "AirAttack";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.is_current_animation_finished() {
Some(FighterState::Fall(FallState::new(self.airborne_state_common)))
} else {
None
}
}
fn update(&mut self, state_context: &mut UpdateContext) {
let animation_frame = state_context.get_current_animation_frame();
let script = state_context
.attack_scripts
.get(&state_context.attack_script_assets.scripts[&self.attack_kind]);
if let Some(script) = script {
update_active_hitbox_list(
script,
&mut state_context
.hitboxes
.0
.as_mut()
.expect("ground attack must have active hitboxes")
.active_hitboxes,
animation_frame,
);
}
air::update_standard_motion(state_context, &mut self.airborne_state_common);
}
fn on_enter(&mut self, state_context: &mut EnterContext) {
state_context.play_animation(self.attack_kind.get_animation(), false);
let script = state_context.attack_script_assets.scripts[&self.attack_kind].clone();
state_context.hitboxes.0 = Some(crate::fighter::FighterActiveAttack {
kind: self.attack_kind,
script,
active_hitboxes: Vec::new(),
solved_hitboxes: Vec::new(),
hit_fighters: Vec::new(),
stalled: false,
});
}
fn on_exit(&mut self, state_context: &mut ExitContext) {
state_context.hitboxes.0 = None;
}
fn check_collision_interrupt(
&mut self,
state_context: &mut CollisionContext,
) -> Option<FighterState> {
air::collision_interrupt(
state_context,
crate::fighter::state::land::LandingType::Normal,
false,
)
}
fn is_visually_grounded<'a>(&'a self) -> bool {
false
}
}
#[derive(thiserror::Error, Debug)]
pub enum AirAttackStateCreateError {
#[error("Attack script: {attack:?} not found")]
AttackScriptNotFound{attack: AttackKind},
}
impl AirAttackState {
pub fn create(attack_kind: AttackKind, attack_script_assets: &FighterAttackScriptAssets, airborne_state_common: &AirborneStateCommon) -> Result<Self, AirAttackStateCreateError> {
if attack_script_assets.scripts.contains_key(&attack_kind) {
Ok(AirAttackState {
airborne_state_common: airborne_state_common.clone(),
attack_kind
})
} else {
Err(AirAttackStateCreateError::AttackScriptNotFound { attack: attack_kind })
}
}
}
pub fn check_interrupt(state_context: &InterruptContext, airborne_state_common: &AirborneStateCommon) -> Option<FighterState> {
if state_context
.input
.has_command(crate::input::FighterCommands::Attack)
{
let attack_kind = state_context
.input
.get_attack_command_type()
.to_attack_kind_airborne(state_context.facing_direction);
let state = AirAttackState::create(attack_kind, state_context.attack_script_assets, airborne_state_common);
match state {
Ok(state) => {
Some(FighterState::AirAttack(state))
},
Err(create_error) => {
error!("{}", create_error);
None
},
}
} else {
None
}
}
+7 -29
View File
@@ -1,11 +1,7 @@
use crate::{ use crate::{
fighter::{ fighter::{
FighterInvulnerabilityState, animation::AnimKind, state::{ animation::AnimKind, state::{
CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, fall::FallState, land::LandingType,
UpdateContext,
air::{self, AirborneStateCommon},
fall::FallState,
land::LandingType,
}, },
}, input, math::vec::FGVec2, }, input, math::vec::FGVec2,
}; };
@@ -20,21 +16,13 @@ impl FighterStateImpl for AirDodgeState {
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.is_current_animation_finished() { if state_context.is_current_animation_finished() {
Some(FighterState::Fall(FallState::new( Some(FighterState::Fall(FallState::new(AirborneStateCommon::default())))
AirborneStateCommon::default(),
)))
} else { } else {
None None
} }
} }
fn update(&mut self, state_context: &mut UpdateContext) { fn update(&mut self, state_context: &mut UpdateContext) {
let airdodge_intangible_end = state_context.fighter_attributes.air_dodge_intangible_start + state_context.fighter_attributes.air_dodge_intangible_duration;
if state_context.animation_frame == state_context.fighter_attributes.air_dodge_intangible_start {
*state_context.vulnerability = FighterInvulnerabilityState::Intangible;
} else if state_context.animation_frame >= airdodge_intangible_end {
*state_context.vulnerability = FighterInvulnerabilityState::Vulnerable;
}
state_context.velocity.0 *= state_context.fighter_attributes.air_dodge_decay; state_context.velocity.0 *= state_context.fighter_attributes.air_dodge_decay;
air::apply_air_motion( air::apply_air_motion(
@@ -48,7 +36,7 @@ impl FighterStateImpl for AirDodgeState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
air::collision_interrupt(state_context, LandingType::Special, false) air::collision_interrupt(state_context, LandingType::Special)
} }
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
@@ -58,21 +46,11 @@ impl FighterStateImpl for AirDodgeState {
self.direction * state_context.fighter_attributes.air_dodge_velocity; self.direction * state_context.fighter_attributes.air_dodge_velocity;
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
false false
} }
fn surface_policy(&self) -> super::SurfacePolicy {
super::SurfacePolicy::Airborne(crate::fighter::surface::EcbLockPolicy::StartOnDetach)
}
fn on_exit(&mut self, state_context: &mut super::ExitContext) {
*state_context.vulnerability = FighterInvulnerabilityState::Vulnerable;
}
fn name(&self) -> String {
Self::NAME.to_owned()
}
} }
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> {
-53
View File
@@ -1,53 +0,0 @@
use crate::{fighter::{animation::AnimKind, state::{FighterState, FighterStateImpl, air::AirborneStateCommon, fall::FallState}}, math::vec::FGVec2};
#[derive(Debug, Clone, Hash)]
pub struct AngelState {
frames_remaining: u32
}
impl AngelState {
pub fn new(duration: u32) -> Self {
Self {
frames_remaining: duration
}
}
}
impl FighterStateImpl for AngelState {
const NAME: &'static str = "Angel";
fn check_interrupt(&self, state_context: &mut super::InterruptContext) -> Option<super::FighterState> {
let moved_sideways = !state_context.input.get_last_frame().movement.x.is_zero();
let moved_vertically = !state_context.input.get_last_frame().movement.y.is_zero();
if self.frames_remaining == 0 || moved_sideways || moved_vertically {
Some(FighterState::Fall(FallState::new(AirborneStateCommon::default())))
} else {
None
}
}
fn update(&mut self, _state_context: &mut super::UpdateContext) {
self.frames_remaining = self.frames_remaining.saturating_sub(1);
}
fn is_visually_grounded<'a>(&'a self) -> bool {
false
}
fn on_enter(&mut self, state_context: &mut super::EnterContext) {
state_context.play_animation(AnimKind::Wait, true);
state_context.velocity.0 = FGVec2::ZERO;
}
fn check_collision_interrupt(
&mut self,
_state_context: &mut super::CollisionContext,
) -> Option<super::FighterState> {
None
}
fn surface_policy(&self) -> super::SurfacePolicy {
super::SurfacePolicy::Airborne(crate::fighter::surface::EcbLockPolicy::Clear)
}
}
-160
View File
@@ -1,160 +0,0 @@
use super::{
CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext, ground,
};
use crate::fighter::FighterSurfaceState;
use crate::fighter::animation::AnimKind;
use crate::fighter::state::air::AirborneStateCommon;
use crate::fighter::state::ground::{
grounded_movement_common_interrupts, grounded_movement_standstill_common_interrupts,
};
use crate::fighter::state::platform_pass::PlatformPassState;
use crate::fighter::state::{FighterState, dash, ground_attack, turn, walk};
use crate::input::FighterCommands;
use crate::stage::line::StagePolyType;
#[derive(Debug, Clone, Hash)]
pub enum CrouchSubState {
Entering { pass_queued: bool },
Idle,
Exiting,
}
#[derive(Debug, Clone, Hash)]
pub struct CrouchState {
sub_state: CrouchSubState,
}
pub fn crouch_idle_interrupts(state_context: &InterruptContext) -> Option<FighterState> {
if let Some(state) = ground_attack::check_interrupt(state_context) {
Some(state)
} else if let Some(state) = dash::check_input(state_context) {
Some(state)
} else if let Some(state) = turn::check_input(state_context) {
Some(state)
} else if let Some(state) = walk::check_input(state_context) {
Some(state)
} else {
None
}
}
pub fn crouch_entering_interrupts(state_context: &InterruptContext) -> Option<FighterState> {
if let Some(state) = ground_attack::check_interrupt(state_context) {
Some(state)
} else {
None
}
}
impl FighterStateImpl for CrouchState {
const NAME: &'static str = "Crouch";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if let Some(state) = grounded_movement_common_interrupts(state_context) {
return Some(state);
}
match self.sub_state {
CrouchSubState::Entering { pass_queued } => {
// pass
if let FighterSurfaceState::Grounded { line_id } = state_context.surface {
if let Some(stage_poly) = state_context
.stage_collision
.stage_polys
.get(line_id.polygon)
{
let is_inputting_crouch = -state_context.game_settings.input_common.run_stick_threshold > state_context.input.get_last_frame().movement.y;
let is_on_a_soft_platform = matches!(stage_poly.poly_type, StagePolyType::Platform);
if pass_queued && is_inputting_crouch && is_on_a_soft_platform
{
return Some(FighterState::PlatformPass(PlatformPassState::new(
AirborneStateCommon::default(),
line_id.polygon,
)));
}
}
}
// While entering the crouch animation we do not allow movement inputs, but we do allow attacks and jumps
if state_context.is_current_animation_finished() {
return Some(FighterState::Crouch(CrouchState {
sub_state: CrouchSubState::Idle,
}));
} else {
return crouch_entering_interrupts(state_context);
}
}
CrouchSubState::Idle => {
if state_context.input.get_last_frame().movement.y
> -state_context.game_settings.input_common.run_stick_threshold
{
return Some(FighterState::Crouch(CrouchState {
sub_state: CrouchSubState::Exiting,
}));
} else {
return crouch_idle_interrupts(state_context);
}
}
CrouchSubState::Exiting => {
if state_context.is_current_animation_finished() {
return grounded_movement_standstill_common_interrupts(state_context);
} else {
return crouch_idle_interrupts(state_context);
}
}
}
}
fn on_enter(&mut self, state_context: &mut EnterContext) {
match self.sub_state {
CrouchSubState::Entering { .. } => {
state_context.play_animation(AnimKind::CrouchEnter, false);
}
CrouchSubState::Idle => {
state_context.play_animation(AnimKind::CrouchIdle, true);
}
CrouchSubState::Exiting => {
state_context.play_animation(AnimKind::CrouchExit, false);
}
}
}
fn update(&mut self, state_context: &mut UpdateContext) {
if let CrouchSubState::Entering {
pass_queued,
} = &mut self.sub_state
{
if state_context
.input
.has_command(FighterCommands::SmashMoveDown)
{
state_context
.input
.clear_command(FighterCommands::SmashMoveDown);
*pass_queued = true;
}
}
ground::update_friction_motion(state_context);
}
fn check_collision_interrupt(
&mut self,
state_context: &mut CollisionContext,
) -> Option<FighterState> {
ground::collision_interrupt(state_context, false)
}
fn is_visually_grounded<'a>(&'a self) -> bool {
true
}
}
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> {
let input_frame = state_context.input.get_last_frame();
if input_frame.movement.y <= -state_context.game_settings.input_common.run_stick_threshold {
return Some(FighterState::Crouch(CrouchState {
sub_state: CrouchSubState::Entering { pass_queued: false },
}));
}
None
}
+27 -12
View File
@@ -5,43 +5,52 @@ use super::{
use crate::fighter::FighterFacingDirection; use crate::fighter::FighterFacingDirection;
use crate::fighter::animation::AnimKind; use crate::fighter::animation::AnimKind;
use crate::fighter::state::FighterState; use crate::fighter::state::FighterState;
use crate::fighter::state::ground::GroundedStateCommon;
use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight}; use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight};
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
pub struct DashState { pub struct DashState {
direction: FighterFacingDirection, direction: FighterFacingDirection,
pub ground_common: GroundedStateCommon,
} }
impl DashState { impl DashState {
pub fn new(direction: FighterFacingDirection) -> Self { pub fn new(direction: FighterFacingDirection, ground_common: GroundedStateCommon) -> Self {
Self { direction } Self {
direction,
ground_common,
}
} }
} }
impl FighterStateImpl for DashState { impl FighterStateImpl for DashState {
const NAME: &'static str = "Dash"; const NAME: &'static str = "Dash";
fn check_interrupt(&self, ctx: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, ctx: &mut InterruptContext) -> Option<FighterState> {
let ground = &self.ground_common;
let attributes = ctx.fighter_attributes; let attributes = ctx.fighter_attributes;
// A reverse dash can interrupt before IASA // A reverse dash can interrupt before IASA
if let Some(direction) = if let Some(direction) =
check_smash_input_with_dir(ctx).filter(|direction| *direction != self.direction) check_smash_input_with_dir(ctx).filter(|direction| *direction != self.direction)
{ {
return Some(FighterState::Dash(DashState::new(direction))); return Some(FighterState::Dash(DashState::new(
direction,
ground.clone(),
)));
} }
if let Some(state) = ground::grounded_movement_common_interrupts(ctx) { if let Some(state) = ground::grounded_movement_common_interrupts(ctx, ground) {
return Some(state); return Some(state);
} }
if ctx.is_current_animation_finished() { if ctx.is_current_animation_finished() {
return run::check_input(ctx) return run::check_input(ctx, ground)
.or_else(|| walk::check_input(ctx)) .or_else(|| walk::check_input(ctx, ground))
.or_else(|| wait::check_input(ctx)); .or_else(|| wait::check_input(ctx, ground));
} }
if ctx.get_current_animation_frame() > attributes.dash_acceleration_duration { if ctx.get_current_animation_frame() > attributes.dash_acceleration_duration {
return run::check_input(ctx); return run::check_input(ctx, ground);
} }
None None
@@ -97,10 +106,12 @@ impl FighterStateImpl for DashState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
ground::collision_interrupt(state_context, true) ground::collision_interrupt(state_context, &mut self.ground_common, true)
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
@@ -115,6 +126,10 @@ fn check_smash_input_with_dir(state_context: &InterruptContext) -> Option<Fighte
} }
} }
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input(
check_smash_input_with_dir(state_context).map(|dir| FighterState::Dash(DashState::new(dir))) state_context: &InterruptContext,
ground_common: &GroundedStateCommon,
) -> Option<FighterState> {
check_smash_input_with_dir(state_context)
.map(|dir| FighterState::Dash(DashState::new(dir, ground_common.clone())))
} }
+3 -7
View File
@@ -1,7 +1,7 @@
use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext}; use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext};
use crate::fighter::animation::AnimKind; use crate::fighter::animation::AnimKind;
use crate::fighter::state::air::AirborneStateCommon; use crate::fighter::state::air::AirborneStateCommon;
use crate::fighter::state::{FighterState, air, air_attack, jump}; use crate::fighter::state::{FighterState, air, jump};
#[derive(Debug, Clone, Copy, Hash, Default)] #[derive(Debug, Clone, Copy, Hash, Default)]
pub struct FallState { pub struct FallState {
@@ -20,7 +20,7 @@ impl FighterStateImpl for FallState {
const NAME: &'static str = "Fall"; const NAME: &'static str = "Fall";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
jump::check_input_double_jump(state_context).or_else(|| air_attack::check_interrupt(state_context, &self.airborne_state_common)) jump::check_input_double_jump(state_context)
} }
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
@@ -38,7 +38,6 @@ impl FighterStateImpl for FallState {
air::collision_interrupt( air::collision_interrupt(
state_context, state_context,
crate::fighter::state::land::LandingType::Normal, crate::fighter::state::land::LandingType::Normal,
true,
) )
} }
@@ -47,8 +46,5 @@ impl FighterStateImpl for FallState {
) -> bool { ) -> bool {
false false
} }
fn surface_policy(&self) -> super::SurfacePolicy {
super::SurfacePolicy::Airborne(crate::fighter::surface::EcbLockPolicy::StartOnDetach)
}
} }
+31 -137
View File
@@ -3,7 +3,7 @@
//! bit-for-bit deterministic for rollback. //! bit-for-bit deterministic for rollback.
use crate::fighter::state::{ use crate::fighter::state::{
CollisionContext, FighterState, InterruptContext, air, crouch, dash, fall, ground_attack, turn, wait, walk, CollisionContext, FighterState, InterruptContext, air, dash, fall, ground_attack, turn, wait, walk,
}; };
use crate::fighter::{ use crate::fighter::{
FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity,
@@ -13,6 +13,13 @@ use crate::math::int::{FGWide, FGi32};
use crate::math::vec::FGVec2; use crate::math::vec::FGVec2;
use crate::stage::line::{StageLineID, StagePoly, StagePolyLineSegment, StagePolyLineSegmentType}; use crate::stage::line::{StageLineID, StagePoly, StagePolyLineSegment, StagePolyLineSegmentType};
use bevy::prelude::*;
#[derive(Reflect, Hash, Clone, Debug)]
pub struct GroundedStateCommon {
pub current_line_id: StageLineID,
}
pub fn apply_grounded_friction(friction: FGi32, ground_vel: FGi32) -> FGi32 { pub fn apply_grounded_friction(friction: FGi32, ground_vel: FGi32) -> FGi32 {
if friction.abs() > ground_vel.abs() { if friction.abs() > ground_vel.abs() {
-ground_vel -ground_vel
@@ -174,23 +181,22 @@ pub fn update_friction_motion(ctx: &mut crate::fighter::state::UpdateContext) {
pub fn collide_with_stage_grounded( pub fn collide_with_stage_grounded(
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
ground_common: &mut GroundedStateCommon,
can_walk_off: bool, can_walk_off: bool,
) -> GroundedMotionResult { ) -> GroundedMotionResult {
let translation = &mut state_context.translation; let translation = &mut state_context.translation;
let ecb = &state_context.ecb; let ecb = state_context.ecb;
let stage_collision = state_context.stage_collision; let stage_collision = state_context.stage_collision;
let Some(current_line_id) = state_context.surface.line_id() else { let stage_poly = stage_collision
return GroundedMotionResult::InAir; .stage_polys
}; .get(ground_common.current_line_id.polygon);
let stage_poly = stage_collision.stage_polys.get(current_line_id.polygon);
if let Some(stage_poly) = stage_poly { if let Some(stage_poly) = stage_poly {
let ecb_bottom_pos = translation.0 + ecb.get_bottom_point(); let ecb_bottom_pos = translation.0 + ecb.get_bottom_point();
match resolve_grounded_position( match resolve_grounded_position(
stage_poly, stage_poly,
current_line_id.segment, ground_common.current_line_id.segment,
ecb_bottom_pos, ecb_bottom_pos,
can_walk_off, can_walk_off,
) { ) {
@@ -210,45 +216,31 @@ pub fn collide_with_stage_grounded(
.0 .0
.project_onto_normalized(current_segment.normal) .project_onto_normalized(current_segment.normal)
}; };
state_context.surface.land(StageLineID { ground_common.current_line_id.segment = segment_idx;
polygon: current_line_id.polygon,
segment: segment_idx,
});
GroundedMotionResult::InGround GroundedMotionResult::InGround
} }
GroundedPositionResult::InAir => { GroundedPositionResult::InAir => GroundedMotionResult::InAir,
state_context.surface.apply_airborne_policy(
crate::fighter::surface::EcbLockPolicy::StartOnDetach,
state_context.leave_ground_ecb_lock_duration,
);
GroundedMotionResult::InAir
}
} }
} else { } else {
state_context.surface.apply_airborne_policy(
crate::fighter::surface::EcbLockPolicy::StartOnDetach,
state_context.leave_ground_ecb_lock_duration,
);
GroundedMotionResult::InAir GroundedMotionResult::InAir
} }
} }
pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Option<FighterState> { pub fn collision_interrupt(
match collide_with_stage_grounded(ctx, can_walk_off) { ctx: &mut CollisionContext,
common: &mut GroundedStateCommon,
can_walk_off: bool,
) -> Option<FighterState> {
match collide_with_stage_grounded(ctx, common, can_walk_off) {
GroundedMotionResult::InGround => None, GroundedMotionResult::InGround => None,
GroundedMotionResult::InAir => Some(FighterState::Fall(fall::FallState::new( GroundedMotionResult::InAir => Some(FighterState::Fall(fall::FallState::new(air::AirborneStateCommon::default()))),
air::AirborneStateCommon::default(),
))),
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::{FighterECB, FighterSurfaceState};
use crate::stage::line::StagePolyType; use crate::stage::line::StagePolyType;
use crate::input::FighterInput;
fn two_segment_floor() -> StagePoly { fn two_segment_floor() -> StagePoly {
StagePoly::build( StagePoly::build(
@@ -354,126 +346,28 @@ mod tests {
GroundedPositionResult::InAir GroundedPositionResult::InAir
)); ));
} }
#[test]
fn grounded_collision_updates_surface_segment() {
let stage_collision = crate::stage::line::StageCollision {
stage_polys: vec![two_segment_floor()],
};
let mut surface = FighterSurfaceState::Grounded {
line_id: StageLineID {
polygon: 0,
segment: 0,
},
};
let mut translation = FighterTranslation(FGVec2::lit("11", "0"));
let previous_translation = FighterPreviousTranslation(translation.0);
let mut velocity = FighterVelocity::default();
let mut ecb = FighterECB {
bottom: FGVec2::lit("0.0", "-0.5"),
top: FGVec2::lit("0.0", "0.5"),
left: FGVec2::lit("-0.5", "0.0"),
right: FGVec2::lit("0.5", "0.0"),
};
let previous_ecb = FighterPreviousECB::from(ecb);
let mut presentation_snap = crate::fighter::FighterPresentationSnap::default();
let mut context = CollisionContext {
translation: &mut translation,
prev_translation: &previous_translation,
input: &FighterInput::default(),
velocity: &mut velocity,
surface: &mut surface,
ecb: &mut ecb,
prev_ecb: &previous_ecb,
stage_collision: &stage_collision,
leave_ground_ecb_lock_duration: 10,
presentation_snap: &mut presentation_snap,
downward_stick_threshold: FGi32::ONE,
};
assert!(matches!(
collide_with_stage_grounded(&mut context, true),
GroundedMotionResult::InGround
));
assert_eq!(
surface.line_id(),
Some(StageLineID {
polygon: 0,
segment: 1,
})
);
}
#[test]
fn walking_off_edge_clears_surface_contact() {
let stage_collision = crate::stage::line::StageCollision {
stage_polys: vec![StagePoly::build(
StagePolyType::Platform,
&[
(FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor),
],
)],
};
let mut surface = FighterSurfaceState::Grounded {
line_id: StageLineID {
polygon: 0,
segment: 0,
},
};
let mut translation = FighterTranslation(FGVec2::lit("11", "0"));
let previous_translation = FighterPreviousTranslation(translation.0);
let mut velocity = FighterVelocity::default();
let mut ecb = FighterECB {
bottom: FGVec2::lit("0.0", "-0.5"),
top: FGVec2::lit("0.0", "0.5"),
left: FGVec2::lit("-0.5", "0.0"),
right: FGVec2::lit("0.5", "0.0"),
};
let previous_ecb = FighterPreviousECB::from(ecb);
let mut presentation_snap = crate::fighter::FighterPresentationSnap::default();
let mut context = CollisionContext {
translation: &mut translation,
prev_translation: &previous_translation,
input: &FighterInput::default(),
velocity: &mut velocity,
surface: &mut surface,
ecb: &mut ecb,
prev_ecb: &previous_ecb,
stage_collision: &stage_collision,
leave_ground_ecb_lock_duration: 10,
presentation_snap: &mut presentation_snap,
downward_stick_threshold: FGi32::ONE,
};
assert!(matches!(
collide_with_stage_grounded(&mut context, true),
GroundedMotionResult::InAir
));
assert!(matches!(surface, FighterSurfaceState::Airborne { .. }));
}
} }
pub fn grounded_movement_common_interrupts( pub fn grounded_movement_common_interrupts(
state_context: &InterruptContext, state_context: &InterruptContext,
ground_common: &GroundedStateCommon,
) -> Option<FighterState> { ) -> Option<FighterState> {
super::jump::check_input(state_context) super::jump::check_input(state_context, ground_common)
} }
pub fn grounded_movement_standstill_common_interrupts( pub fn grounded_movement_standstill_common_interrupts(
state_context: &InterruptContext, state_context: &InterruptContext,
ground_common: &GroundedStateCommon,
) -> Option<FighterState> { ) -> Option<FighterState> {
if let Some(state) = ground_attack::check_interrupt(state_context) { if let Some(state) = ground_attack::check_interrupt(state_context, &ground_common) {
Some(state) Some(state)
} else if let Some(state) = dash::check_input(state_context) { } else if let Some(state) = dash::check_input(state_context, &ground_common) {
Some(state) Some(state)
} else if let Some(state) = turn::check_input(state_context) { } else if let Some(state) = turn::check_input(state_context, &ground_common) {
Some(state) Some(state)
} else if let Some(state) = crouch::check_input(state_context) { } else if let Some(state) = walk::check_input(state_context, &ground_common) {
Some(state) Some(state)
} else if let Some(state) = walk::check_input(state_context) { } else if let Some(state) = wait::check_input(state_context, &ground_common) {
Some(state)
} else if let Some(state) = wait::check_input(state_context) {
Some(state) Some(state)
} else { } else {
None None
+14 -5
View File
@@ -2,13 +2,14 @@ use crate::fighter::{
attack::{AttackKind, update_active_hitbox_list}, attack::{AttackKind, update_active_hitbox_list},
state::{ state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl,
InterruptContext, UpdateContext, wait::WaitState, InterruptContext, UpdateContext, ground::GroundedStateCommon, wait::WaitState,
}, },
}; };
#[derive(Clone, Debug, Hash)] #[derive(Clone, Debug, Hash)]
pub struct GroundAttackState { pub struct GroundAttackState {
pub attack_kind: AttackKind, pub attack_kind: AttackKind,
pub grounded_common: GroundedStateCommon,
} }
impl FighterStateImpl for GroundAttackState { impl FighterStateImpl for GroundAttackState {
@@ -16,7 +17,9 @@ impl FighterStateImpl for GroundAttackState {
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.is_current_animation_finished() { if state_context.is_current_animation_finished() {
Some(FighterState::Wait(WaitState)) Some(FighterState::Wait(WaitState {
grounded_common: self.grounded_common.clone(),
}))
} else { } else {
None None
} }
@@ -50,7 +53,7 @@ impl FighterStateImpl for GroundAttackState {
active_hitboxes: Vec::new(), active_hitboxes: Vec::new(),
solved_hitboxes: Vec::new(), solved_hitboxes: Vec::new(),
hit_fighters: Vec::new(), hit_fighters: Vec::new(),
stalled: false, stalled: false
}); });
} }
@@ -65,12 +68,17 @@ impl FighterStateImpl for GroundAttackState {
None None
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
pub fn check_interrupt(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_interrupt(
state_context: &InterruptContext,
grounded_common: &GroundedStateCommon,
) -> Option<FighterState> {
if state_context if state_context
.input .input
.has_command(crate::input::FighterCommands::Attack) .has_command(crate::input::FighterCommands::Attack)
@@ -81,6 +89,7 @@ pub fn check_interrupt(state_context: &InterruptContext) -> Option<FighterState>
.to_attack_kind_grounded(); .to_attack_kind_grounded();
Some(FighterState::GroundAttack(GroundAttackState { Some(FighterState::GroundAttack(GroundAttackState {
attack_kind, attack_kind,
grounded_common: grounded_common.clone(),
})) }))
} else { } else {
None None
+20 -23
View File
@@ -7,16 +7,17 @@ use crate::{
InterruptContext, UpdateContext, InterruptContext, UpdateContext,
air::{self, AirborneStateCommon}, air::{self, AirborneStateCommon},
fall::FallState, fall::FallState,
ground::collision_interrupt, ground::{GroundedStateCommon, collision_interrupt},
land::LandingType, land::LandingType,
wait::WaitState, wait::WaitState,
}, },
}, math::{int::FGi32, vec::FGVec2}, },
math::int::FGi32,
}; };
#[derive(Clone, Debug, Hash)] #[derive(Clone, Debug, Hash)]
pub enum HitstopStateKind { pub enum HitstopStateKind {
Grounded, Grounded(GroundedStateCommon),
Airborne, Airborne,
} }
@@ -42,7 +43,7 @@ impl FighterStateImpl for HitstunState {
fn name(&self) -> String { fn name(&self) -> String {
let hitstop_kind = match self.hitstop_kind { let hitstop_kind = match self.hitstop_kind {
HitstopStateKind::Grounded => "Grounded", HitstopStateKind::Grounded(_) => "Grounded",
HitstopStateKind::Airborne => "Airborne", HitstopStateKind::Airborne => "Airborne",
}; };
@@ -57,10 +58,14 @@ impl FighterStateImpl for HitstunState {
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.get_current_animation_frame() > self.hit_reaction.hitstun_frames { if state_context.get_current_animation_frame() > self.hit_reaction.hitstun_frames {
match &self.hitstop_kind { match &self.hitstop_kind {
HitstopStateKind::Grounded => Some(FighterState::Wait(WaitState)), HitstopStateKind::Grounded(grounded_state_common) => {
HitstopStateKind::Airborne => Some(FighterState::Fall(FallState::new( Some(FighterState::Wait(WaitState {
AirborneStateCommon::default(), grounded_common: grounded_state_common.clone(),
))), }))
}
HitstopStateKind::Airborne => {
Some(FighterState::Fall(FallState::new(AirborneStateCommon::default())))
}
} }
} else { } else {
None None
@@ -80,7 +85,7 @@ impl FighterStateImpl for HitstunState {
} }
match self.hitstop_kind { match self.hitstop_kind {
HitstopStateKind::Grounded => {} HitstopStateKind::Grounded(_) => {}
HitstopStateKind::Airborne => { HitstopStateKind::Airborne => {
air::integrate_gravity(ctx.velocity, ctx.fighter_attributes, false); air::integrate_gravity(ctx.velocity, ctx.fighter_attributes, false);
} }
@@ -98,11 +103,10 @@ impl FighterStateImpl for HitstunState {
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
let animation = match self.hitstop_kind { let animation = match self.hitstop_kind {
HitstopStateKind::Grounded => AnimKind::FlinchGround, HitstopStateKind::Grounded(_) => AnimKind::FlinchGround,
HitstopStateKind::Airborne => AnimKind::FlinchAir, HitstopStateKind::Airborne => AnimKind::FlinchAir,
}; };
state_context.play_animation(animation, false); state_context.play_animation(animation, false);
state_context.velocity.0 = FGVec2::ZERO;
} }
fn on_exit(&mut self, _state_context: &mut ExitContext) {} fn on_exit(&mut self, _state_context: &mut ExitContext) {}
@@ -112,26 +116,19 @@ impl FighterStateImpl for HitstunState {
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
match &mut self.hitstop_kind { match &mut self.hitstop_kind {
HitstopStateKind::Grounded => collision_interrupt(state_context, true), HitstopStateKind::Grounded(grounded_state_common) => {
collision_interrupt(state_context, grounded_state_common, true)
}
HitstopStateKind::Airborne => { HitstopStateKind::Airborne => {
air::collision_interrupt(state_context, LandingType::Normal, false) air::collision_interrupt(state_context, LandingType::Normal)
} }
} }
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(&'a self) -> bool {
match self.hitstop_kind { match self.hitstop_kind {
HitstopStateKind::Grounded => true, HitstopStateKind::Grounded(_) => true,
HitstopStateKind::Airborne => false, HitstopStateKind::Airborne => false,
} }
} }
fn surface_policy(&self) -> super::SurfacePolicy {
match self.hitstop_kind {
HitstopStateKind::Grounded => super::SurfacePolicy::Grounded,
HitstopStateKind::Airborne => super::SurfacePolicy::Airborne(
crate::fighter::surface::EcbLockPolicy::StartOnDetach,
),
}
}
} }
+30 -35
View File
@@ -2,17 +2,10 @@ use bevy::prelude::*;
use crate::{ use crate::{
fighter::{ fighter::{
animation::AnimKind, animation::AnimKind, state::{
state::{ CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, air_dodge::{self, AirDodgeState}, ground::{self, GroundedStateCommon},
CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext,
UpdateContext,
air::{self, AirborneStateCommon},
air_dodge::{self, AirDodgeState},
ground,
}, },
}, }, input::FighterCommands, math::{int::FGi32, vec::FGVec2},
input::FighterCommands,
math::{int::FGi32, vec::FGVec2},
}; };
#[derive(Debug, Hash, Clone, Copy)] #[derive(Debug, Hash, Clone, Copy)]
pub enum JumpType { pub enum JumpType {
@@ -23,18 +16,20 @@ pub enum JumpType {
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
pub struct JumpState { pub struct JumpState {
pub jump_type: JumpType, jump_type: JumpType,
pub airborne_state_common: AirborneStateCommon, airborne_state_common: AirborneStateCommon,
} }
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
pub struct JumpSquatState { pub struct JumpSquatState {
pub grounded_common: GroundedStateCommon,
pub duration_counter: u32, pub duration_counter: u32,
} }
impl JumpSquatState { impl JumpSquatState {
pub fn create() -> Self { pub fn create(grounded_common: GroundedStateCommon) -> Self {
Self { Self {
grounded_common,
duration_counter: 0, duration_counter: 0,
} }
} }
@@ -74,10 +69,7 @@ impl FighterStateImpl for JumpSquatState {
} else { } else {
JumpType::ShortHop JumpType::ShortHop
}; };
Some(FighterState::Jump(JumpState { Some(FighterState::Jump(JumpState { jump_type, airborne_state_common: AirborneStateCommon::default() }))
jump_type,
airborne_state_common: AirborneStateCommon::default(),
}))
} else { } else {
None None
} }
@@ -92,7 +84,7 @@ impl FighterStateImpl for JumpSquatState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
ground::collision_interrupt(state_context, false) ground::collision_interrupt(state_context, &mut self.grounded_common, false)
} }
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
@@ -100,7 +92,9 @@ impl FighterStateImpl for JumpSquatState {
state_context.input.clear_command(FighterCommands::Jump); state_context.input.clear_command(FighterCommands::Jump);
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
@@ -108,7 +102,14 @@ impl FighterStateImpl for JumpSquatState {
impl FighterStateImpl for JumpState { impl FighterStateImpl for JumpState {
const NAME: &'static str = "Jump"; const NAME: &'static str = "Jump";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
air::air_interrupts_common(state_context, &self.airborne_state_common) if state_context.input.has_command(FighterCommands::Jump) {
Some(FighterState::Jump(JumpState {
jump_type: JumpType::DoubleJump,
airborne_state_common: AirborneStateCommon::default()
}))
} else {
air_dodge::check_input(state_context)
}
} }
fn update(&mut self, state_context: &mut UpdateContext) { fn update(&mut self, state_context: &mut UpdateContext) {
@@ -173,30 +174,24 @@ impl FighterStateImpl for JumpState {
air::collision_interrupt( air::collision_interrupt(
state_context, state_context,
crate::fighter::state::land::LandingType::Normal, crate::fighter::state::land::LandingType::Normal,
true,
) )
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
false false
} }
fn surface_policy(&self) -> super::SurfacePolicy {
use crate::fighter::surface::EcbLockPolicy;
match self.jump_type {
JumpType::DoubleJump => super::SurfacePolicy::Airborne(EcbLockPolicy::Restart),
JumpType::ShortHop | JumpType::LongJump => {
super::SurfacePolicy::Airborne(EcbLockPolicy::StartOnDetach)
}
}
}
} }
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input(
state_context: &InterruptContext,
ground_common: &GroundedStateCommon,
) -> Option<FighterState> {
state_context state_context
.input .input
.has_command(FighterCommands::Jump) .has_command(FighterCommands::Jump)
.then(|| FighterState::JumpSquat(JumpSquatState::create())) .then(|| FighterState::JumpSquat(JumpSquatState::create(ground_common.clone())))
} }
pub fn check_input_double_jump(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input_double_jump(state_context: &InterruptContext) -> Option<FighterState> {
@@ -206,7 +201,7 @@ pub fn check_input_double_jump(state_context: &InterruptContext) -> Option<Fight
.then(|| { .then(|| {
FighterState::Jump(JumpState { FighterState::Jump(JumpState {
jump_type: JumpType::DoubleJump, jump_type: JumpType::DoubleJump,
airborne_state_common: AirborneStateCommon::default(), airborne_state_common: AirborneStateCommon::default()
}) })
}) })
} }
+15 -9
View File
@@ -3,7 +3,7 @@ use crate::fighter::{
state::{ state::{
CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext,
UpdateContext, dash, UpdateContext, dash,
ground::{self, grounded_movement_standstill_common_interrupts}, ground::{self, GroundedStateCommon, grounded_movement_standstill_common_interrupts},
walk, walk,
}, },
}; };
@@ -16,12 +16,16 @@ pub enum LandingType {
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
pub struct LandingState { pub struct LandingState {
pub grounded_common: GroundedStateCommon,
pub landing_type: LandingType, pub landing_type: LandingType,
} }
impl LandingState { impl LandingState {
pub fn create(landing_type: LandingType) -> Self { pub fn create(grounded_common: GroundedStateCommon, landing_type: LandingType) -> Self {
Self { landing_type } Self {
grounded_common,
landing_type,
}
} }
} }
@@ -30,14 +34,14 @@ impl FighterStateImpl for LandingState {
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.is_current_animation_finished() { if state_context.is_current_animation_finished() {
grounded_movement_standstill_common_interrupts(state_context) grounded_movement_standstill_common_interrupts(state_context, &self.grounded_common)
} else if state_context.get_current_animation_frame() } else if state_context.get_current_animation_frame()
>= state_context.fighter_attributes.landing_iasa >= state_context.fighter_attributes.landing_iasa
&& let LandingType::Normal = self.landing_type && let LandingType::Normal = self.landing_type
{ {
dash::check_input(state_context).or_else(|| { dash::check_input(state_context, &self.grounded_common).or_else(|| {
ground::grounded_movement_common_interrupts(state_context) ground::grounded_movement_common_interrupts(state_context, &self.grounded_common)
.or_else(|| walk::check_input(state_context)) .or_else(|| walk::check_input(state_context, &self.grounded_common))
}) })
} else { } else {
None None
@@ -52,7 +56,7 @@ impl FighterStateImpl for LandingState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
ground::collision_interrupt(state_context, true) ground::collision_interrupt(state_context, &mut self.grounded_common, true)
} }
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
@@ -66,7 +70,9 @@ impl FighterStateImpl for LandingState {
}; };
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
+41 -186
View File
@@ -7,34 +7,25 @@ use bevy::prelude::*;
use crate::fighter::animation::{AnimKind, FighterAnimationFrame}; use crate::fighter::animation::{AnimKind, FighterAnimationFrame};
use crate::fighter::attack::{ use crate::fighter::attack::{
FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction, FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction, FighterStaleMoveQueue, HitStop,
FighterStaleMoveQueue, HitStop,
}; };
use crate::fighter::attributes::FighterAttributes; use crate::fighter::attributes::FighterAttributes;
use crate::fighter::baked_animation::BakedFighterAnimations; use crate::fighter::baked_animation::BakedFighterAnimations;
use crate::fighter::ecb::FighterPreviousECB; use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::manifest::FighterManifest; use crate::fighter::manifest::FighterManifest;
use crate::fighter::state::hitstun::HitstunState; use crate::fighter::state::hitstun::HitstunState;
use crate::fighter::surface::EcbLockPolicy;
use crate::fighter::visual::FighterAnimations; use crate::fighter::visual::FighterAnimations;
use crate::fighter::{ use crate::fighter::{
Fighter, FighterDamage, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, FighterInvulnerabilityState, FighterPendingRespawn, FighterPresentationSnap, FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity, Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, FighterPreviousTranslation, FighterTranslation, FighterVelocity,
}; };
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
use crate::input::FighterInput; use crate::input::FighterInput;
use crate::match_loading::MatchConfig;
use crate::match_runtime::MatchState;
use crate::math::int::FGi32; use crate::math::int::FGi32;
use crate::math::vec::FGVec2;
use crate::player::Player;
use crate::scripting::FighterAttackScript; use crate::scripting::FighterAttackScript;
use crate::stage::line::StageCollision; use crate::stage::line::StageCollision;
use crate::stage::manifest::{StageManifest, StageManifestRegistry};
pub mod air; pub mod air;
pub mod air_dodge; pub mod air_dodge;
pub mod angel;
pub mod crouch;
pub mod dash; pub mod dash;
pub mod fall; pub mod fall;
pub mod ground; pub mod ground;
@@ -42,18 +33,10 @@ pub mod ground_attack;
pub mod hitstun; pub mod hitstun;
pub mod jump; pub mod jump;
pub mod land; pub mod land;
pub mod platform_pass;
pub mod run; pub mod run;
pub mod turn; pub mod turn;
pub mod wait; pub mod wait;
pub mod walk; pub mod walk;
pub mod air_attack;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum SurfacePolicy {
Grounded,
Airborne(EcbLockPolicy),
}
macro_rules! fighter_states { macro_rules! fighter_states {
($($variant:ident => $ty:ty),* $(,)?) => { ($($variant:ident => $ty:ty),* $(,)?) => {
@@ -86,10 +69,6 @@ macro_rules! fighter_states {
pub fn is_visually_grounded(&self) -> bool { pub fn is_visually_grounded(&self) -> bool {
match self { $(Self::$variant(s) => s.is_visually_grounded()),* } match self { $(Self::$variant(s) => s.is_visually_grounded()),* }
} }
pub fn surface_policy(&self) -> SurfacePolicy {
match self { $(Self::$variant(s) => s.surface_policy()),* }
}
} }
$(impl From<$ty> for FighterState { $(impl From<$ty> for FighterState {
@@ -116,11 +95,9 @@ pub trait FighterStateImpl: Sized {
None None
} }
fn is_visually_grounded<'a>(&'a self) -> bool; fn is_visually_grounded<'a>(
&'a self,
fn surface_policy(&self) -> SurfacePolicy { ) -> bool;
SurfacePolicy::Grounded
}
} }
pub struct AnimationStatus { pub struct AnimationStatus {
@@ -133,10 +110,7 @@ pub struct InterruptContext<'a> {
pub facing_direction: &'a mut FighterFacingDirection, pub facing_direction: &'a mut FighterFacingDirection,
pub game_settings: &'a GameSettings, pub game_settings: &'a GameSettings,
pub fighter_attributes: &'a FighterAttributes, pub fighter_attributes: &'a FighterAttributes,
pub surface: &'a FighterSurfaceState,
pub animation: AnimationStatus, pub animation: AnimationStatus,
pub stage_collision: &'a StageCollision,
pub attack_script_assets: &'a FighterAttackScriptAssets,
} }
impl InterruptContext<'_> { impl InterruptContext<'_> {
@@ -157,10 +131,8 @@ pub struct EnterContext<'a> {
pub stale_move_queue: &'a mut FighterStaleMoveQueue, pub stale_move_queue: &'a mut FighterStaleMoveQueue,
pub facing_direction: &'a mut FighterFacingDirection, pub facing_direction: &'a mut FighterFacingDirection,
pub animation_frame: &'a mut FighterAnimationFrame, pub animation_frame: &'a mut FighterAnimationFrame,
pub surface: &'a mut FighterSurfaceState,
pub fighter_attributes: &'a FighterAttributes, pub fighter_attributes: &'a FighterAttributes,
pub attack_script_assets: &'a FighterAttackScriptAssets, pub attack_script_assets: &'a FighterAttackScriptAssets,
pub game_settings: &'a GameSettings,
} }
impl EnterContext<'_> { impl EnterContext<'_> {
@@ -171,8 +143,6 @@ impl EnterContext<'_> {
pub struct ExitContext<'a> { pub struct ExitContext<'a> {
pub hitboxes: &'a mut FighterHitboxes, pub hitboxes: &'a mut FighterHitboxes,
pub surface: &'a mut FighterSurfaceState,
pub vulnerability: &'a mut FighterInvulnerabilityState,
} }
pub struct UpdateContext<'a> { pub struct UpdateContext<'a> {
@@ -181,9 +151,7 @@ pub struct UpdateContext<'a> {
pub input: &'a mut FighterInput, pub input: &'a mut FighterInput,
pub hitboxes: &'a mut FighterHitboxes, pub hitboxes: &'a mut FighterHitboxes,
pub velocity: &'a mut FighterVelocity, pub velocity: &'a mut FighterVelocity,
pub surface: &'a mut FighterSurfaceState,
pub knockback_velocity: &'a mut FighterKnockbackVelocity, pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub vulnerability: &'a mut FighterInvulnerabilityState,
pub game_settings: &'a GameSettings, pub game_settings: &'a GameSettings,
pub animation_frame: u32, pub animation_frame: u32,
pub fighter_attributes: &'a FighterAttributes, pub fighter_attributes: &'a FighterAttributes,
@@ -201,33 +169,22 @@ impl UpdateContext<'_> {
pub struct CollisionContext<'a> { pub struct CollisionContext<'a> {
pub translation: &'a mut FighterTranslation, pub translation: &'a mut FighterTranslation,
pub prev_translation: &'a FighterPreviousTranslation, pub prev_translation: &'a FighterPreviousTranslation,
pub input: &'a FighterInput,
pub velocity: &'a mut FighterVelocity, pub velocity: &'a mut FighterVelocity,
pub surface: &'a mut FighterSurfaceState, pub ecb: &'a FighterECB,
pub ecb: &'a mut FighterECB,
pub prev_ecb: &'a FighterPreviousECB, pub prev_ecb: &'a FighterPreviousECB,
pub stage_collision: &'a StageCollision, pub stage_collision: &'a StageCollision,
pub leave_ground_ecb_lock_duration: u32,
pub presentation_snap: &'a mut FighterPresentationSnap,
pub downward_stick_threshold: FGi32,
} }
impl CollisionContext<'_> { impl CollisionContext<'_> {
pub fn air_collide_with_stage( pub fn air_collide_with_stage(
&self, &self,
can_ignore_soft_platforms: bool,
) -> Option<crate::fighter::collision::AirCollisionWithStageResult> { ) -> Option<crate::fighter::collision::AirCollisionWithStageResult> {
let ignored_polys = self.surface.ignored_polygon_ids();
let ignore_soft_platforms = can_ignore_soft_platforms
&& self.input.get_last_frame().movement.y <= -self.downward_stick_threshold;
crate::fighter::collision::air_collide_with_stage( crate::fighter::collision::air_collide_with_stage(
self.prev_translation, self.prev_translation,
self.translation, self.translation,
self.prev_ecb, self.prev_ecb,
self.ecb, self.ecb,
self.stage_collision, self.stage_collision,
&ignored_polys,
ignore_soft_platforms,
) )
} }
} }
@@ -235,8 +192,6 @@ impl CollisionContext<'_> {
fighter_states! { fighter_states! {
Wait => wait::WaitState, Wait => wait::WaitState,
Walk => walk::WalkState, Walk => walk::WalkState,
PlatformPass => platform_pass::PlatformPassState,
Crouch => crouch::CrouchState,
Dash => dash::DashState, Dash => dash::DashState,
Run => run::RunState, Run => run::RunState,
Fall => fall::FallState, Fall => fall::FallState,
@@ -247,8 +202,6 @@ fighter_states! {
Turn => turn::TurnState, Turn => turn::TurnState,
GroundAttack => ground_attack::GroundAttackState, GroundAttack => ground_attack::GroundAttackState,
Hitstun => hitstun::HitstunState, Hitstun => hitstun::HitstunState,
Angel => angel::AngelState,
AirAttack => air_attack::AirAttackState,
} }
#[derive(QueryData)] #[derive(QueryData)]
@@ -256,18 +209,15 @@ fighter_states! {
pub struct FighterFrameQuery { pub struct FighterFrameQuery {
pub translation: &'static mut FighterTranslation, pub translation: &'static mut FighterTranslation,
pub prev_translation: &'static mut FighterPreviousTranslation, pub prev_translation: &'static mut FighterPreviousTranslation,
pub presentation_snap: &'static mut FighterPresentationSnap,
pub input: &'static mut FighterInput, pub input: &'static mut FighterInput,
pub hitboxes: &'static mut FighterHitboxes, pub hitboxes: &'static mut FighterHitboxes,
pub velocity: &'static mut FighterVelocity, pub velocity: &'static mut FighterVelocity,
pub surface: &'static mut FighterSurfaceState,
pub knockback_velocity: &'static mut FighterKnockbackVelocity, pub knockback_velocity: &'static mut FighterKnockbackVelocity,
pub stale_move_queue: &'static mut FighterStaleMoveQueue, pub stale_move_queue: &'static mut FighterStaleMoveQueue,
pub ecb: &'static mut FighterECB, pub ecb: &'static FighterECB,
pub prev_ecb: &'static FighterPreviousECB, pub prev_ecb: &'static FighterPreviousECB,
pub facing_direction: &'static mut FighterFacingDirection, pub facing_direction: &'static mut FighterFacingDirection,
pub animation_frame: &'static mut FighterAnimationFrame, pub animation_frame: &'static mut FighterAnimationFrame,
pub vulnerability: &'static mut FighterInvulnerabilityState,
pub fighter: &'static Fighter, pub fighter: &'static Fighter,
} }
@@ -294,8 +244,6 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
animations: &'a FighterAnimations, animations: &'a FighterAnimations,
baked_animations: &'a Assets<BakedFighterAnimations>, baked_animations: &'a Assets<BakedFighterAnimations>,
attributes: &'a FighterAttributes, attributes: &'a FighterAttributes,
stage_collision: &'a StageCollision,
attack_script_assets: &'a FighterAttackScriptAssets,
) -> InterruptContext<'a> { ) -> InterruptContext<'a> {
let animation = self.animation_status(animations, baked_animations); let animation = self.animation_status(animations, baked_animations);
InterruptContext { InterruptContext {
@@ -304,9 +252,6 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
game_settings, game_settings,
fighter_attributes: attributes, fighter_attributes: attributes,
animation, animation,
surface: &self.surface,
stage_collision,
attack_script_assets: attack_script_assets
} }
} }
@@ -314,7 +259,6 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
&'a mut self, &'a mut self,
attributes: &'a FighterAttributes, attributes: &'a FighterAttributes,
attack_script_assets: &'a FighterAttackScriptAssets, attack_script_assets: &'a FighterAttackScriptAssets,
game_settings: &'a GameSettings,
) -> EnterContext<'a> { ) -> EnterContext<'a> {
EnterContext { EnterContext {
input: &mut self.input, input: &mut self.input,
@@ -324,18 +268,14 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
stale_move_queue: &mut self.stale_move_queue, stale_move_queue: &mut self.stale_move_queue,
facing_direction: &mut self.facing_direction, facing_direction: &mut self.facing_direction,
animation_frame: &mut self.animation_frame, animation_frame: &mut self.animation_frame,
surface: &mut self.surface,
fighter_attributes: attributes, fighter_attributes: attributes,
attack_script_assets, attack_script_assets,
game_settings,
} }
} }
fn exit_context(&mut self) -> ExitContext<'_> { fn exit_context(&mut self) -> ExitContext<'_> {
ExitContext { ExitContext {
hitboxes: &mut self.hitboxes, hitboxes: &mut self.hitboxes,
surface: &mut self.surface,
vulnerability: &mut self.vulnerability,
} }
} }
@@ -352,37 +292,27 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
input: &mut self.input, input: &mut self.input,
hitboxes: &mut self.hitboxes, hitboxes: &mut self.hitboxes,
velocity: &mut self.velocity, velocity: &mut self.velocity,
surface: &mut self.surface,
knockback_velocity: &mut self.knockback_velocity, knockback_velocity: &mut self.knockback_velocity,
game_settings, game_settings,
animation_frame: self.animation_frame.frame, animation_frame: self.animation_frame.frame,
vulnerability: &mut self.vulnerability,
fighter_attributes: attributes, fighter_attributes: attributes,
attack_scripts, attack_scripts,
attack_script_assets, attack_script_assets,
facing_direction: &self.facing_direction, facing_direction: &self.facing_direction
} }
} }
fn collision_context<'a>( fn collision_context<'a>(
&'a mut self, &'a mut self,
stage_collision: &'a StageCollision, stage_collision: &'a StageCollision,
game_settings: &'a GameSettings,
) -> CollisionContext<'a> { ) -> CollisionContext<'a> {
CollisionContext { CollisionContext {
translation: &mut self.translation, translation: &mut self.translation,
prev_translation: &self.prev_translation, prev_translation: &self.prev_translation,
presentation_snap: &mut self.presentation_snap,
input: &self.input,
velocity: &mut self.velocity, velocity: &mut self.velocity,
surface: &mut self.surface, ecb: &self.ecb,
ecb: &mut self.ecb,
prev_ecb: &self.prev_ecb, prev_ecb: &self.prev_ecb,
stage_collision, stage_collision,
leave_ground_ecb_lock_duration: game_settings
.fighter_common
.leave_ground_ecb_frame_lock_duration,
downward_stick_threshold: game_settings.input_common.run_stick_threshold,
} }
} }
} }
@@ -395,12 +325,9 @@ pub struct StatefulFighterQuery {
pub animations: &'static FighterAnimations, pub animations: &'static FighterAnimations,
pub fighter: &'static Fighter, pub fighter: &'static Fighter,
pub attack_script_assets: &'static FighterAttackScriptAssets, pub attack_script_assets: &'static FighterAttackScriptAssets,
pub player: &'static Player,
pub pending_hit_reaction: &'static mut FighterPendingHitReaction, pub pending_hit_reaction: &'static mut FighterPendingHitReaction,
pub hitstop: &'static mut FighterHitstop, pub hitstop: &'static mut FighterHitstop,
pub hitstop_shake: &'static mut FighterHitstopShake, pub hitstop_shake: &'static mut FighterHitstopShake,
pub pending_respawn: &'static mut FighterPendingRespawn,
pub damage: &'static mut FighterDamage,
} }
pub fn state_update_system( pub fn state_update_system(
@@ -440,21 +367,12 @@ fn transition_state(
frame: &mut FighterFrameQueryItem, frame: &mut FighterFrameQueryItem,
attributes: &FighterAttributes, attributes: &FighterAttributes,
attack_script_assets: &FighterAttackScriptAssets, attack_script_assets: &FighterAttackScriptAssets,
game_settings: &GameSettings,
) { ) {
let old_name = current.name(); let old_name = current.name();
let new_name = next.name(); let new_name = next.name();
current.on_exit(&mut frame.exit_context()); current.on_exit(&mut frame.exit_context());
if let SurfacePolicy::Airborne(lock_policy) = next.surface_policy() { next.on_enter(&mut frame.enter_context(attributes, attack_script_assets));
frame.surface.apply_airborne_policy(
lock_policy,
game_settings
.fighter_common
.leave_ground_ecb_frame_lock_duration,
);
}
next.on_enter(&mut frame.enter_context(attributes, attack_script_assets, game_settings));
*current = next; *current = next;
info!("{} -> {}", old_name, new_name); info!("{} -> {}", old_name, new_name);
} }
@@ -464,7 +382,6 @@ pub fn state_interrupt_system(
game_settings: Res<GameSettings>, game_settings: Res<GameSettings>,
baked_animations: Res<Assets<BakedFighterAnimations>>, baked_animations: Res<Assets<BakedFighterAnimations>>,
fighter_manifests: Res<Assets<FighterManifest>>, fighter_manifests: Res<Assets<FighterManifest>>,
stage_collision: Res<StageCollision>,
) { ) {
for fighter in &mut query { for fighter in &mut query {
let StatefulFighterQueryItem { let StatefulFighterQueryItem {
@@ -476,7 +393,6 @@ pub fn state_interrupt_system(
mut pending_hit_reaction, mut pending_hit_reaction,
mut hitstop, mut hitstop,
mut hitstop_shake, mut hitstop_shake,
..
} = fighter; } = fighter;
let manifest = fighter_manifests let manifest = fighter_manifests
.get(&fighter.manifest) .get(&fighter.manifest)
@@ -484,9 +400,7 @@ pub fn state_interrupt_system(
let new_state = if let Some(reaction) = pending_hit_reaction.take() { let new_state = if let Some(reaction) = pending_hit_reaction.take() {
hitstop.remaining_frames = *reaction.hitstop_frames; hitstop.remaining_frames = *reaction.hitstop_frames;
let shake_frames = reaction let shake_frames = reaction.hitstop_frames.calculate_shake_frames(&game_settings);
.hitstop_frames
.calculate_shake_frames(&game_settings);
hitstop_shake.role = FighterHitstopShakeRole::Receiver; hitstop_shake.role = FighterHitstopShakeRole::Receiver;
hitstop_shake.total_frames = shake_frames; hitstop_shake.total_frames = shake_frames;
hitstop_shake.remaining_frames = shake_frames; hitstop_shake.remaining_frames = shake_frames;
@@ -496,25 +410,35 @@ pub fn state_interrupt_system(
FighterHitstopShakeKind::Vertical FighterHitstopShakeKind::Vertical
}; };
let mut hitstop_kind = if frame.surface.is_grounded() { let mut hitstop_kind = match &*state {
hitstun::HitstopStateKind::Grounded FighterState::Wait(value) => {
} else { hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
hitstun::HitstopStateKind::Airborne }
FighterState::Walk(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Dash(value) => {
hitstun::HitstopStateKind::Grounded(value.ground_common.clone())
}
FighterState::Run(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Turn(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::GroundAttack(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
_ => hitstun::HitstopStateKind::Airborne,
}; };
if let hitstun::HitstopStateKind::Grounded = hitstop_kind { if let hitstun::HitstopStateKind::Grounded(_) = hitstop_kind {
let straight_up_angle = FGi32::lit("90.0"); let straight_up_angle = FGi32::lit("90.0");
if reaction.hitstop_frames if reaction.hitstop_frames >= HitStop::new(game_settings.fighter_common.tumble_bounce_hitstop_threshold) {
>= HitStop::new(game_settings.fighter_common.tumble_bounce_hitstop_threshold)
{
hitstop_kind = hitstun::HitstopStateKind::Airborne; hitstop_kind = hitstun::HitstopStateKind::Airborne;
// TODO: Use floor normal here // TODO: Use floor normal here
} else if reaction.launch_angle.abs_diff(straight_up_angle) } else if reaction.launch_angle.abs_diff(straight_up_angle) < game_settings.fighter_common.attack_ground_to_airborne_transition_vertical_angle_threshold {
< game_settings hitstop_kind = hitstun::HitstopStateKind::Airborne;
.fighter_common
.attack_ground_to_airborne_transition_vertical_angle_threshold
{
hitstop_kind = hitstun::HitstopStateKind::Airborne;
} else { } else {
// straight down is 270 // straight down is 270
let straight_down_angle = FGi32::lit("270.0"); let straight_down_angle = FGi32::lit("270.0");
@@ -539,8 +463,6 @@ pub fn state_interrupt_system(
animations, animations,
&baked_animations, &baked_animations,
&manifest.attributes, &manifest.attributes,
&stage_collision,
attack_script_assets
)) ))
}; };
@@ -551,7 +473,6 @@ pub fn state_interrupt_system(
&mut frame, &mut frame,
&manifest.attributes, &manifest.attributes,
attack_script_assets, attack_script_assets,
&game_settings,
); );
}; };
} }
@@ -561,90 +482,25 @@ pub fn state_collision_interrupt_system(
mut query: Query<StatefulFighterQuery>, mut query: Query<StatefulFighterQuery>,
stage_collision: Res<StageCollision>, stage_collision: Res<StageCollision>,
fighter_manifests: Res<Assets<FighterManifest>>, fighter_manifests: Res<Assets<FighterManifest>>,
game_settings: Res<GameSettings>,
match_config: Res<MatchConfig>,
mut match_state: ResMut<MatchState>,
stage_registry: Res<StageManifestRegistry>,
stage_manifests: Res<Assets<StageManifest>>,
) { ) {
let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else {
return;
};
let Some(stage_manifest) = stage_manifests.get(&stage_manifest_handle) else {
return;
};
for fighter in &mut query { for fighter in &mut query {
let StatefulFighterQueryItem { let StatefulFighterQueryItem {
mut state, mut state,
mut frame, mut frame,
fighter, fighter,
attack_script_assets, attack_script_assets,
mut hitstop, hitstop,
mut pending_respawn,
player,
mut damage,
.. ..
} = fighter; } = fighter;
let manifest = fighter_manifests
.get(&fighter.manifest)
.expect("Fighter manifest should be valid");
if pending_respawn.0 {
**pending_respawn = false;
let respawn_state = FighterState::Angel(angel::AngelState::new(60 * 3));
// Find the angel platform position
let respawn_point = stage_manifest
.respawn_points
.get(player.controller_port % stage_manifest.respawn_points.len());
if let None = respawn_point {
error!("Couldn't get a respawn point, falling back to the world origin");
}
let respawn_point = respawn_point.copied().unwrap_or(FGVec2::ZERO);
frame.translation.0 = respawn_point;
frame.presentation_snap.0 = true;
// Clear hitstop
hitstop.remaining_frames = 0;
// subtract one stock
let player_state = match_state.players.get_mut(player.handle).expect(&format!(
"Failed to get fighter for GGRS slot {} (controller port {})",
player.handle, player.controller_port
));
if let Some(stocks_remaining) = &mut player_state.stocks_remaining {
*stocks_remaining = stocks_remaining.saturating_sub(1);
}
// reset percentage
*damage = FighterDamage::default();
transition_state(
&mut state,
respawn_state,
&mut frame,
&manifest.attributes,
attack_script_assets,
&game_settings,
);
continue;
}
if hitstop.remaining_frames > 0 { if hitstop.remaining_frames > 0 {
continue; continue;
} }
let new_state = state.check_collision_interrupt( let manifest = fighter_manifests
&mut frame.collision_context(&stage_collision, &game_settings), .get(&fighter.manifest)
); .expect("Fighter manifest should be valid");
let new_state =
state.check_collision_interrupt(&mut frame.collision_context(&stage_collision));
if let Some(new_state) = new_state { if let Some(new_state) = new_state {
transition_state( transition_state(
&mut state, &mut state,
@@ -652,7 +508,6 @@ pub fn state_collision_interrupt_system(
&mut frame, &mut frame,
&manifest.attributes, &manifest.attributes,
&attack_script_assets, &attack_script_assets,
&game_settings,
); );
} }
} }
-62
View File
@@ -1,62 +0,0 @@
use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext};
use crate::fighter::animation::AnimKind;
use crate::fighter::state::air::AirborneStateCommon;
use crate::fighter::state::{ExitContext, FighterState, air, jump};
#[derive(Debug, Clone, Copy, Hash, Default)]
pub struct PlatformPassState {
airborne_state_common: AirborneStateCommon,
ignored_polygon: usize,
}
impl PlatformPassState {
pub fn new(airborne_state_common: AirborneStateCommon, ignored_polygon: usize) -> Self {
Self {
airborne_state_common,
ignored_polygon
}
}
}
impl FighterStateImpl for PlatformPassState {
const NAME: &'static str = "PlatformPass";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
jump::check_input_double_jump(state_context)
}
fn on_enter(&mut self, state_context: &mut EnterContext) {
state_context.surface.ignore_polygon(self.ignored_polygon, state_context.game_settings.fighter_common.platform_pass_platform_ignore_duration_frames);
state_context.velocity.y = state_context.game_settings.fighter_common.platform_pass_initial_downwards_velocity;
state_context.play_animation(AnimKind::PlatformPass, false);
}
fn on_exit(&mut self, state_context: &mut ExitContext) {
state_context.surface.clear_polygon_ignore(self.ignored_polygon);
}
fn update(&mut self, state_context: &mut UpdateContext) {
air::update_standard_motion(state_context, &mut self.airborne_state_common);
}
fn check_collision_interrupt(
&mut self,
state_context: &mut CollisionContext,
) -> Option<FighterState> {
air::collision_interrupt(
state_context,
crate::fighter::state::land::LandingType::Normal,
true,
)
}
fn is_visually_grounded<'a>(
&'a self,
) -> bool {
false
}
fn surface_policy(&self) -> super::SurfacePolicy {
super::SurfacePolicy::Airborne(crate::fighter::surface::EcbLockPolicy::StartOnDetach)
}
}
+20 -7
View File
@@ -6,14 +6,19 @@ use super::{
}; };
use crate::fighter::animation::AnimKind; use crate::fighter::animation::AnimKind;
use crate::fighter::state::FighterState; use crate::fighter::state::FighterState;
use crate::fighter::state::ground::GroundedStateCommon;
#[derive(Component, Debug, Clone, Hash)] #[derive(Component, Debug, Clone, Hash)]
pub struct RunState; pub struct RunState {
pub grounded_common: GroundedStateCommon,
}
impl FighterStateImpl for RunState { impl FighterStateImpl for RunState {
const NAME: &'static str = "Run"; const NAME: &'static str = "Run";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if let Some(state) = ground::grounded_movement_common_interrupts(state_context) { if let Some(state) =
ground::grounded_movement_common_interrupts(state_context, &self.grounded_common)
{
return Some(state); return Some(state);
} }
@@ -23,7 +28,8 @@ impl FighterStateImpl for RunState {
{ {
None None
} else { } else {
walk::check_input(state_context).or_else(|| wait::check_input(state_context)) walk::check_input(state_context, &self.grounded_common)
.or_else(|| wait::check_input(state_context, &self.grounded_common))
} }
} }
@@ -56,19 +62,26 @@ impl FighterStateImpl for RunState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
ground::collision_interrupt(state_context, true) ground::collision_interrupt(state_context, &mut self.grounded_common, true)
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input(
state_context: &InterruptContext,
ground_state_common: &GroundedStateCommon,
) -> Option<FighterState> {
let input_frame = state_context.input.get_last_frame(); let input_frame = state_context.input.get_last_frame();
if input_frame.movement.x.abs() >= state_context.game_settings.input_common.run_stick_threshold if input_frame.movement.x.abs() >= state_context.game_settings.input_common.run_stick_threshold
{ {
return Some(FighterState::Run(RunState)); return Some(FighterState::Run(RunState {
grounded_common: ground_state_common.clone(),
}));
} }
None None
} }
+20 -11
View File
@@ -1,11 +1,13 @@
use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext}; use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext};
use crate::fighter::animation::AnimKind; use crate::fighter::animation::AnimKind;
use crate::fighter::state::ground; use crate::fighter::state::ground::{self, GroundedStateCommon};
use crate::fighter::state::{FighterState, dash, jump, wait, walk}; use crate::fighter::state::{FighterState, dash, jump, wait, walk};
use crate::input::FighterCommands; use crate::input::FighterCommands;
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
pub struct TurnState; pub struct TurnState {
pub grounded_common: GroundedStateCommon,
}
impl FighterStateImpl for TurnState { impl FighterStateImpl for TurnState {
const NAME: &'static str = "Turn"; const NAME: &'static str = "Turn";
@@ -17,19 +19,19 @@ impl FighterStateImpl for TurnState {
.input_common .input_common
.turn_into_dashback_window as u32 .turn_into_dashback_window as u32
{ {
if let Some(state) = dash::check_input(state_context) { if let Some(state) = dash::check_input(state_context, &self.grounded_common) {
return Some(state); return Some(state);
} }
} }
if let Some(state) = jump::check_input(state_context) { if let Some(state) = jump::check_input(state_context, &self.grounded_common) {
*state_context.facing_direction = state_context.facing_direction.reverse(); *state_context.facing_direction = state_context.facing_direction.reverse();
Some(state) Some(state)
} else if state_context.is_current_animation_finished() { } else if state_context.is_current_animation_finished() {
if let Some(state) = dash::check_input(state_context) { if let Some(state) = dash::check_input(state_context, &self.grounded_common) {
Some(state) Some(state)
} else if let Some(state) = walk::check_input(state_context) { } else if let Some(state) = walk::check_input(state_context, &self.grounded_common) {
Some(state) Some(state)
} else if let Some(state) = wait::check_input(state_context) { } else if let Some(state) = wait::check_input(state_context, &self.grounded_common) {
Some(state) Some(state)
} else { } else {
None None
@@ -52,15 +54,20 @@ impl FighterStateImpl for TurnState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
ground::collision_interrupt(state_context, false) ground::collision_interrupt(state_context, &mut self.grounded_common, false)
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input(
state_context: &InterruptContext,
grounded_common: &GroundedStateCommon,
) -> Option<FighterState> {
let input_frame = state_context.input.get_last_frame(); let input_frame = state_context.input.get_last_frame();
let has_smash_input = state_context let has_smash_input = state_context
@@ -74,7 +81,9 @@ pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> {
&& input_frame.movement.x.signum() != state_context.facing_direction.to_sign() && input_frame.movement.x.signum() != state_context.facing_direction.to_sign()
&& !has_smash_input && !has_smash_input
{ {
return Some(FighterState::Turn(TurnState)); return Some(FighterState::Turn(TurnState {
grounded_common: grounded_common.clone(),
}));
} }
None None
} }
+22 -9
View File
@@ -3,18 +3,24 @@ use super::{
}; };
use crate::fighter::animation::AnimKind; use crate::fighter::animation::AnimKind;
use crate::fighter::state::FighterState; use crate::fighter::state::FighterState;
use crate::fighter::state::ground::GroundedStateCommon;
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
pub struct WaitState; pub struct WaitState {
pub grounded_common: GroundedStateCommon,
}
impl FighterStateImpl for WaitState { impl FighterStateImpl for WaitState {
const NAME: &'static str = "Wait"; const NAME: &'static str = "Wait";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if let Some(state) = ground::grounded_movement_common_interrupts(state_context) { if let Some(state) =
Some(state) ground::grounded_movement_common_interrupts(state_context, &self.grounded_common)
} else if let Some(state) =
ground::grounded_movement_standstill_common_interrupts(state_context)
{ {
Some(state)
} else if let Some(state) = ground::grounded_movement_standstill_common_interrupts(
state_context,
&self.grounded_common,
) {
// Don't transition into yourself // Don't transition into yourself
if let FighterState::Wait(_) = state { if let FighterState::Wait(_) = state {
None None
@@ -38,19 +44,26 @@ impl FighterStateImpl for WaitState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
ground::collision_interrupt(state_context, false) ground::collision_interrupt(state_context, &mut self.grounded_common, false)
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input(
state_context: &InterruptContext,
ground_state_common: &GroundedStateCommon,
) -> Option<FighterState> {
let input_frame = state_context.input.get_last_frame(); let input_frame = state_context.input.get_last_frame();
if input_frame.movement.x.abs() < state_context.game_settings.input_common.stick_deadzone { if input_frame.movement.x.abs() < state_context.game_settings.input_common.stick_deadzone {
return Some(FighterState::Wait(WaitState)); return Some(FighterState::Wait(WaitState {
grounded_common: ground_state_common.clone(),
}));
} }
None None
} }
+21 -8
View File
@@ -3,18 +3,24 @@ use super::{
ground, wait, ground, wait,
}; };
use crate::fighter::animation::AnimKind; use crate::fighter::animation::AnimKind;
use crate::fighter::state::{self, FighterState}; use crate::fighter::state::FighterState;
use crate::fighter::state::ground::GroundedStateCommon;
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
pub struct WalkState; pub struct WalkState {
pub grounded_common: GroundedStateCommon,
}
impl FighterStateImpl for WalkState { impl FighterStateImpl for WalkState {
const NAME: &'static str = "Walk"; const NAME: &'static str = "Walk";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if let Some(state) = ground::grounded_movement_common_interrupts(state_context) { if let Some(state) =
ground::grounded_movement_common_interrupts(state_context, &self.grounded_common)
{
return Some(state); return Some(state);
} }
wait::check_input(state_context).or_else(|| dash::check_input(state_context)).or_else(|| state::ground_attack::check_interrupt(state_context)) wait::check_input(state_context, &self.grounded_common)
.or_else(|| dash::check_input(state_context, &self.grounded_common))
} }
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
@@ -48,19 +54,26 @@ impl FighterStateImpl for WalkState {
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
ground::collision_interrupt(state_context, false) ground::collision_interrupt(state_context, &mut self.grounded_common, false)
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(
&'a self,
) -> bool {
true true
} }
} }
pub fn check_input(state_context: &InterruptContext) -> Option<FighterState> { pub fn check_input(
state_context: &InterruptContext,
ground_state_common: &GroundedStateCommon,
) -> Option<FighterState> {
let input_frame = state_context.input.get_last_frame(); let input_frame = state_context.input.get_last_frame();
if !input_frame.movement.x.is_zero() { if !input_frame.movement.x.is_zero() {
return Some(FighterState::Walk(WalkState)); return Some(FighterState::Walk(WalkState {
grounded_common: ground_state_common.clone(),
}));
} }
None None
-227
View File
@@ -1,227 +0,0 @@
use bevy::prelude::*;
use crate::stage::line::StageLineID;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum EcbLockPolicy {
StartOnDetach,
Restart,
Preserve,
Clear,
}
/// The stage surface currently supporting a fighter.
///
/// This is physics state rather than fighter-state-machine state. Keeping it
/// separate means grounded, landing, and hitstun states all share one source
/// of truth for stage contact.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct IgnoredPolygon {
pub polygon: usize,
pub frames_remaining: u32,
}
#[derive(Component, Clone, Debug, Hash, PartialEq, Eq)]
pub enum FighterSurfaceState {
Airborne {
ecb_lock_frames_remaining: u32,
ignored_polys: Vec<IgnoredPolygon>,
},
Grounded {
line_id: StageLineID,
},
}
impl Default for FighterSurfaceState {
fn default() -> Self {
Self::Airborne {
ecb_lock_frames_remaining: 0,
ignored_polys: vec![],
}
}
}
impl FighterSurfaceState {
pub fn is_grounded(&self) -> bool {
matches!(self, Self::Grounded { .. })
}
pub fn line_id(&self) -> Option<StageLineID> {
match self {
Self::Airborne { .. } => None,
Self::Grounded { line_id } => Some(*line_id),
}
}
pub(crate) fn land(&mut self, line_id: StageLineID) {
*self = Self::Grounded { line_id };
}
pub fn clear_polygon_ignore(&mut self, polygon: usize) {
let Self::Airborne { ignored_polys, .. } = self else {
return;
};
ignored_polys.retain(|element| element.polygon != polygon);
}
pub fn ignore_polygon(&mut self, polygon: usize, duration_frames: u32) {
let Self::Airborne { ignored_polys, .. } = self else {
return;
};
if duration_frames == 0 {
return;
}
if let Some(ignored) = ignored_polys.iter_mut().find(|ignored| ignored.polygon == polygon) {
ignored.frames_remaining = duration_frames;
} else {
ignored_polys.push(IgnoredPolygon {
polygon,
frames_remaining: duration_frames,
});
}
}
pub fn is_polygon_ignored(&self, polygon: usize) -> bool {
matches!(self, Self::Airborne { ignored_polys, .. }
if ignored_polys.iter().any(|ignored| ignored.polygon == polygon))
}
pub(crate) fn ignored_polygon_ids(&self) -> Vec<usize> {
match self {
Self::Airborne { ignored_polys, .. } => {
ignored_polys.iter().map(|ignored| ignored.polygon).collect()
}
Self::Grounded { .. } => vec![],
}
}
pub(crate) fn apply_airborne_policy(
&mut self,
policy: EcbLockPolicy,
ecb_lock_duration: u32,
) {
let current_lock = match self {
Self::Airborne { ecb_lock_frames_remaining, .. } => *ecb_lock_frames_remaining,
Self::Grounded { .. } => 0,
};
let ecb_lock_frames_remaining = match policy {
EcbLockPolicy::StartOnDetach if self.is_grounded() => ecb_lock_duration,
EcbLockPolicy::StartOnDetach => current_lock,
EcbLockPolicy::Restart => ecb_lock_duration,
EcbLockPolicy::Preserve => current_lock,
EcbLockPolicy::Clear => 0,
};
let ignored_polys = match self {
Self::Airborne { ignored_polys, .. } => ignored_polys.clone(),
Self::Grounded { .. } => vec![],
};
*self = Self::Airborne { ecb_lock_frames_remaining, ignored_polys };
}
pub(crate) fn tick_ecb_lock(&mut self) {
if let Self::Airborne { ecb_lock_frames_remaining, .. } = self {
*ecb_lock_frames_remaining = ecb_lock_frames_remaining.saturating_sub(1);
}
}
pub(crate) fn tick_ignored_polygons(&mut self) {
if let Self::Airborne { ignored_polys, .. } = self {
for ignored in ignored_polys.iter_mut() {
ignored.frames_remaining = ignored.frames_remaining.saturating_sub(1);
}
ignored_polys.retain(|ignored| ignored.frames_remaining > 0);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contact_lifecycle_has_one_source_of_truth() {
let mut surface = FighterSurfaceState::default();
assert!(!surface.is_grounded());
assert_eq!(surface.line_id(), None);
let line_id = StageLineID {
polygon: 2,
segment: 3,
};
surface.land(line_id);
assert!(surface.is_grounded());
assert_eq!(surface.line_id(), Some(line_id));
surface.apply_airborne_policy(EcbLockPolicy::Clear, 0);
assert!(!surface.is_grounded());
assert_eq!(surface.line_id(), None);
}
#[test]
fn start_on_detach_does_not_reset_an_existing_lock() {
let mut surface = FighterSurfaceState::default();
surface.apply_airborne_policy(EcbLockPolicy::Restart, 5);
surface.tick_ecb_lock();
surface.apply_airborne_policy(EcbLockPolicy::StartOnDetach, 5);
assert_eq!(
surface,
FighterSurfaceState::Airborne {
ecb_lock_frames_remaining: 4,
ignored_polys: vec![],
}
);
}
#[test]
fn restart_retriggers_an_existing_lock() {
let mut surface = FighterSurfaceState::default();
surface.apply_airborne_policy(EcbLockPolicy::Restart, 5);
surface.tick_ecb_lock();
surface.apply_airborne_policy(EcbLockPolicy::Restart, 5);
assert_eq!(
surface,
FighterSurfaceState::Airborne {
ecb_lock_frames_remaining: 5,
ignored_polys: vec![],
}
);
}
#[test]
fn ignored_polygon_expires_after_requested_frames() {
let mut surface = FighterSurfaceState::default();
surface.ignore_polygon(3, 2);
assert!(surface.is_polygon_ignored(3));
surface.tick_ignored_polygons();
assert!(surface.is_polygon_ignored(3));
surface.tick_ignored_polygons();
assert!(!surface.is_polygon_ignored(3));
}
#[test]
fn ignoring_polygon_refreshes_existing_entry() {
let mut surface = FighterSurfaceState::default();
surface.ignore_polygon(3, 2);
surface.ignore_polygon(3, 5);
surface.tick_ignored_polygons();
surface.tick_ignored_polygons();
assert!(surface.is_polygon_ignored(3));
}
#[test]
fn airborne_policy_preserves_ignored_polygons_and_landing_clears_them() {
let mut surface = FighterSurfaceState::default();
surface.ignore_polygon(3, 5);
surface.apply_airborne_policy(EcbLockPolicy::Restart, 2);
assert!(surface.is_polygon_ignored(3));
surface.land(StageLineID { polygon: 0, segment: 0 });
assert!(!surface.is_polygon_ignored(3));
}
}
+1 -64
View File
@@ -1,8 +1,4 @@
use crate::{ use crate::fighter::baked_animation::BakedFighterAnimations;
fighter::{FighterInvulnerabilityState, baked_animation::BakedFighterAnimations},
game_settings::GameSettings,
materials::fighter_material::{FighterMaterial, FighterMesh},
};
use bevy::prelude::*; use bevy::prelude::*;
#[derive(Component, Clone)] #[derive(Component, Clone)]
@@ -10,62 +6,3 @@ pub struct FighterAnimations {
/// The single animation source for gameplay and the visual skeleton. /// The single animation source for gameplay and the visual skeleton.
pub baked: Handle<BakedFighterAnimations>, pub baked: Handle<BakedFighterAnimations>,
} }
#[derive(Component, Clone, Default)]
pub struct FighterInvulnerabilityVisualState {
pub last_known_state: FighterInvulnerabilityState,
pub frames_in_state: u32,
}
pub fn update_status_colors(
mut fighters: Query<(
&FighterInvulnerabilityState,
&mut FighterInvulnerabilityVisualState,
)>,
meshes: Query<(&FighterMesh, &MeshMaterial3d<FighterMaterial>)>,
mut materials: ResMut<Assets<FighterMaterial>>,
game_settings: Res<GameSettings>,
) {
for (invuln_state, mut invuln_state_visual) in &mut fighters {
if *invuln_state != invuln_state_visual.last_known_state {
invuln_state_visual.last_known_state = *invuln_state;
invuln_state_visual.frames_in_state = 0;
}
}
for (mesh, mat) in meshes {
let (_, invuln_state_visual) = fighters
.get(mesh.fighter)
.expect("Material should have relevant fighter available");
let status_color = match invuln_state_visual.last_known_state {
FighterInvulnerabilityState::Vulnerable => bevy::color::palettes::css::WHITE.into(),
FighterInvulnerabilityState::Invulnerable => {
game_settings.fighter_common.intangible_color
}
FighterInvulnerabilityState::Intangible => {
game_settings.fighter_common.intangible_color
}
};
let intensity = match invuln_state_visual.last_known_state {
FighterInvulnerabilityState::Vulnerable => 0.0,
FighterInvulnerabilityState::Invulnerable | FighterInvulnerabilityState::Intangible => {
let cycle_frames = invuln_state_visual.frames_in_state
% game_settings.fighter_common.intangible_pulse_duration;
1.0 - cycle_frames as f32
/ ((game_settings.fighter_common.intangible_pulse_duration) as f32)
}
};
let mut material = materials
.get_mut(mat.id())
.expect("Material should exist here...");
material
.extension
.update_status_color(status_color.into(), intensity);
}
for (_, mut invuln_state_visual) in fighters {
invuln_state_visual.frames_in_state = invuln_state_visual.frames_in_state.saturating_add(1);
}
}
-30
View File
@@ -1,30 +0,0 @@
#import bevy_pbr::mesh_functions::{get_world_from_local, mesh_position_local_to_clip}
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> material_color: vec4<f32>;
struct Vertex {
@builtin(instance_index) instance_index: u32,
@location(0) position: vec3<f32>,
@location(1) color: vec4<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vertex(vertex: Vertex) -> VertexOutput {
var output: VertexOutput;
output.clip_position = mesh_position_local_to_clip(
get_world_from_local(vertex.instance_index),
vec4<f32>(vertex.position, 1.0),
);
output.color = vertex.color;
return output;
}
@fragment
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
return material_color * input.color;
}
-417
View File
@@ -1,417 +0,0 @@
//! Immediate-mode filled debug geometry.
//!
//! Unlike Bevy's line-only `Gizmos`, this collects triangles into one dynamic
//! mesh. It is intended for small, transient debug overlays rather than game
//! geometry.
use std::f32::consts::{FRAC_PI_2, PI, TAU};
use bevy::{
asset::{load_internal_asset, uuid_handle, RenderAssetUsages},
camera::visibility::NoFrustumCulling,
ecs::system::SystemParam,
mesh::{Indices, MeshVertexBufferLayoutRef, PrimitiveTopology},
pbr::{MaterialPipeline, MaterialPipelineKey},
prelude::*,
render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
},
shader::ShaderRef,
};
const SPHERE_SECTORS: usize = 16;
const SPHERE_STACKS: usize = 8;
const CAPSULE_SECTORS: usize = 16;
const CAPSULE_HEMISPHERE_STEPS: usize = 4;
#[derive(Default)]
struct FilledGizmoGeometry {
positions: Vec<[f32; 3]>,
normals: Vec<[f32; 3]>,
colors: Vec<[f32; 4]>,
indices: Vec<u32>,
}
#[derive(Resource, Default)]
struct FilledGizmoBuffer {
/// Geometry requested by the current schedule invocation.
pending: FilledGizmoGeometry,
/// The last completed fixed-timestep geometry. This is retained between
/// render frames, just like Bevy's fixed-timestep gizmos.
retained: FilledGizmoGeometry,
mesh: Option<Handle<Mesh>>,
fixed_commit: bool,
}
/// Immediate-mode API for drawing filled debug shapes.
#[derive(SystemParam)]
pub struct FilledGizmos<'w> {
buffer: ResMut<'w, FilledGizmoBuffer>,
}
impl FilledGizmos<'_> {
/// Draw a filled planar polygon. Vertices must be ordered around the
/// perimeter and the polygon must be convex.
pub fn polygon(&mut self, vertices: &[Vec3], color: impl Into<Color> + Copy) {
if vertices.len() < 3 {
return;
}
let color = color.into().to_linear().to_f32_array();
let normal = (vertices[1] - vertices[0])
.cross(vertices[2] - vertices[0])
.normalize_or_zero();
let base = self.buffer.pending.positions.len() as u32;
for &vertex in vertices {
self.push_vertex(vertex, normal, color);
}
for index in 2..vertices.len() as u32 {
self.buffer
.pending
.indices
.extend([base, base + index - 1, base + index]);
}
}
pub fn sphere(&mut self, isometry: Isometry3d, radius: f32, color: impl Into<Color> + Copy) {
if radius <= 0.0 {
return;
}
let color = color.into().to_linear().to_f32_array();
let base = self.buffer.pending.positions.len() as u32;
for stack in 0..=SPHERE_STACKS {
let latitude = PI * 0.5 - stack as f32 * PI / SPHERE_STACKS as f32;
let (sin_lat, cos_lat) = latitude.sin_cos();
for sector in 0..=SPHERE_SECTORS {
let longitude = sector as f32 * TAU / SPHERE_SECTORS as f32;
let (sin_lon, cos_lon) = longitude.sin_cos();
let normal = Vec3::new(cos_lat * cos_lon, sin_lat, cos_lat * sin_lon);
self.push_vertex(
isometry.transform_point(normal * radius).into(),
isometry.rotation * normal,
color,
);
}
}
let stride = SPHERE_SECTORS + 1;
for stack in 0..SPHERE_STACKS {
for sector in 0..SPHERE_SECTORS {
let a = base + (stack * stride + sector) as u32;
let b = a + stride as u32;
self.buffer
.pending
.indices
.extend([a, b, a + 1, a + 1, b, b + 1]);
}
}
}
pub fn capsule(
&mut self,
isometry: Isometry3d,
radius: f32,
half_length: f32,
color: impl Into<Color> + Copy,
) {
let transform = Mat4::from_scale_rotation_translation(
Vec3::ONE,
isometry.rotation,
isometry.translation.into(),
);
self.affine_capsule(transform, radius, half_length, color);
}
/// Draw a capsule transformed by an arbitrary affine transform.
pub fn affine_capsule(
&mut self,
transform: Mat4,
radius: f32,
half_length: f32,
color: impl Into<Color> + Copy,
) {
if radius <= 0.0 {
return;
}
let color = color.into().to_linear().to_f32_array();
let base = self.buffer.pending.positions.len() as u32;
let mut rings = Vec::new();
for step in 1..=CAPSULE_HEMISPHERE_STEPS {
let angle = -FRAC_PI_2 + step as f32 * FRAC_PI_2 / CAPSULE_HEMISPHERE_STEPS as f32;
rings.push((
radius * angle.cos(),
-half_length + radius * angle.sin(),
angle.sin(),
));
}
rings.push((radius, half_length, 0.0));
for step in 1..CAPSULE_HEMISPHERE_STEPS {
let angle = step as f32 * FRAC_PI_2 / CAPSULE_HEMISPHERE_STEPS as f32;
rings.push((
radius * angle.cos(),
half_length + radius * angle.sin(),
angle.sin(),
));
}
let mut ring_indices = Vec::new();
let bottom_pole = self.push_capsule_vertex(
&mut ring_indices,
transform,
Vec3::NEG_Y,
-half_length,
radius,
color,
);
for &(ring_radius, y, normal_y) in &rings {
let mut ring = Vec::with_capacity(CAPSULE_SECTORS);
for sector in 0..CAPSULE_SECTORS {
let angle = sector as f32 * TAU / CAPSULE_SECTORS as f32;
let (sin, cos) = angle.sin_cos();
let normal = Vec3::new(
cos * (1.0 - normal_y * normal_y).sqrt(),
normal_y,
sin * (1.0 - normal_y * normal_y).sqrt(),
);
ring.push(self.push_transformed_vertex(
transform,
Vec3::new(cos * ring_radius, y, sin * ring_radius),
normal,
color,
));
}
ring_indices.push(ring);
}
let top_pole = self.push_capsule_vertex(
&mut ring_indices,
transform,
Vec3::Y,
half_length,
radius,
color,
);
for sector in 0..CAPSULE_SECTORS {
let next = (sector + 1) % CAPSULE_SECTORS;
self.buffer.pending.indices.extend([
bottom_pole,
ring_indices[0][next],
ring_indices[0][sector],
]);
for ring in ring_indices.windows(2) {
self.buffer.pending.indices.extend([
ring[0][sector],
ring[0][next],
ring[1][sector],
ring[1][sector],
ring[0][next],
ring[1][next],
]);
}
let last = ring_indices.len() - 1;
self.buffer.pending.indices.extend([
ring_indices[last][sector],
ring_indices[last][next],
top_pole,
]);
}
// Keep the base used by this shape observable to avoid accidental
// future changes making the generated indices refer to another shape.
debug_assert!(base <= bottom_pole);
}
fn push_capsule_vertex(
&mut self,
_rings: &mut Vec<Vec<u32>>,
transform: Mat4,
normal: Vec3,
y: f32,
radius: f32,
color: [f32; 4],
) -> u32 {
self.push_transformed_vertex(
transform,
Vec3::new(0.0, y + normal.y * radius, 0.0),
normal,
color,
)
}
fn push_transformed_vertex(
&mut self,
transform: Mat4,
position: Vec3,
normal: Vec3,
color: [f32; 4],
) -> u32 {
let transformed_normal = transform
.inverse()
.transpose()
.transform_vector3(normal)
.normalize_or_zero();
let index = self.buffer.pending.positions.len() as u32;
self.push_vertex(
transform.transform_point3(position),
transformed_normal,
color,
);
index
}
fn push_vertex(&mut self, position: Vec3, normal: Vec3, color: [f32; 4]) {
self.buffer.pending.positions.push(position.to_array());
self.buffer.pending.normals.push(normal.to_array());
self.buffer.pending.colors.push(color);
}
}
pub struct FilledGizmosPlugin;
const FILLED_GIZMO_SHADER_HANDLE: Handle<Shader> =
uuid_handle!("0e4a979f-ea11-4e5a-91cd-19c89d4e9b53");
/// A deliberately minimal material: unlike `StandardMaterial`, its fragment
/// shader writes vertex colors directly and therefore bypasses PBR lighting
/// and camera tonemapping.
#[derive(Asset, AsBindGroup, Reflect, Clone, Debug)]
struct FilledGizmoMaterial {
#[uniform(0)]
color: LinearRgba,
}
impl Material for FilledGizmoMaterial {
fn vertex_shader() -> ShaderRef {
FILLED_GIZMO_SHADER_HANDLE.clone().into()
}
fn fragment_shader() -> ShaderRef {
FILLED_GIZMO_SHADER_HANDLE.clone().into()
}
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Blend
}
fn enable_prepass() -> bool {
false
}
fn enable_shadows() -> bool {
false
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
layout: &MeshVertexBufferLayoutRef,
_key: MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
descriptor.vertex.buffers = vec![layout.0.get_layout(&[
Mesh::ATTRIBUTE_POSITION.at_shader_location(0),
Mesh::ATTRIBUTE_COLOR.at_shader_location(1),
])?];
descriptor.primitive.cull_mode = None;
Ok(())
}
}
impl Plugin for FilledGizmosPlugin {
fn build(&self, app: &mut App) {
load_internal_asset!(
app,
FILLED_GIZMO_SHADER_HANDLE,
"filled_gizmo.wgsl",
Shader::from_wgsl
);
app.add_plugins(MaterialPlugin::<FilledGizmoMaterial>::default())
.init_resource::<FilledGizmoBuffer>()
.add_systems(Startup, setup_filled_gizmo_mesh)
.add_systems(FixedFirst, begin_fixed_filled_gizmos)
.add_systems(FixedLast, commit_fixed_filled_gizmos)
.add_systems(Last, upload_filled_gizmo_mesh);
}
}
fn setup_filled_gizmo_mesh(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<FilledGizmoMaterial>>,
mut buffer: ResMut<FilledGizmoBuffer>,
) {
let mesh = meshes.add(
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, vec![[0.0; 3]; 3])
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 3])
.with_inserted_attribute(Mesh::ATTRIBUTE_COLOR, vec![[0.0, 0.0, 0.0, 0.0]; 3])
.with_inserted_indices(Indices::U32(vec![0, 1, 2])),
);
let material = materials.add(FilledGizmoMaterial {
color: LinearRgba::WHITE,
});
commands.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material),
NoFrustumCulling,
));
buffer.mesh = Some(mesh);
}
fn begin_fixed_filled_gizmos(mut buffer: ResMut<FilledGizmoBuffer>) {
buffer.pending = FilledGizmoGeometry::default();
buffer.fixed_commit = false;
}
fn commit_fixed_filled_gizmos(mut buffer: ResMut<FilledGizmoBuffer>) {
let pending = std::mem::take(&mut buffer.pending);
buffer.pending = std::mem::replace(&mut buffer.retained, pending);
buffer.fixed_commit = true;
}
fn upload_filled_gizmo_mesh(
mut meshes: ResMut<Assets<Mesh>>,
mut buffer: ResMut<FilledGizmoBuffer>,
) {
// No fixed tick and no render-frame requests means the existing mesh is
// already the correct image. In particular, do not replace it with the
// empty placeholder between fixed updates.
if !buffer.fixed_commit && buffer.pending.positions.is_empty() {
return;
}
let Some(handle) = buffer.mesh.clone() else {
return;
};
let (positions, normals, colors, indices) = if buffer.fixed_commit {
// Retained geometry must remain in the CPU-side buffer after upload;
// otherwise the next render frame would have nothing to draw.
(
buffer.retained.positions.clone(),
buffer.retained.normals.clone(),
buffer.retained.colors.clone(),
buffer.retained.indices.clone(),
)
} else {
(
std::mem::take(&mut buffer.pending.positions),
std::mem::take(&mut buffer.pending.normals),
std::mem::take(&mut buffer.pending.colors),
std::mem::take(&mut buffer.pending.indices),
)
};
if let Some(mut mesh) = meshes.get_mut(&handle) {
if positions.is_empty() {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, vec![[0.0; 3]; 3]);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 3]);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![[0.0, 0.0, 0.0, 0.0]; 3]);
mesh.insert_indices(Indices::U32(vec![0, 1, 2]));
} else {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
mesh.insert_indices(Indices::U32(indices));
}
}
buffer.pending = FilledGizmoGeometry::default();
buffer.fixed_commit = false;
}
-6
View File
@@ -39,12 +39,6 @@ pub struct FighterSettingsCommon {
/// Maximum angle changed caused by directional influence /// Maximum angle changed caused by directional influence
pub di_max_angle: FGi32, pub di_max_angle: FGi32,
pub leave_ground_ecb_frame_lock_duration: u32,
pub platform_pass_platform_ignore_duration_frames: u32,
pub platform_pass_initial_downwards_velocity: FGi32,
pub intangible_color: Color,
pub intangible_pulse_duration: u32,
} }
#[derive(Reflect, Serialize, Deserialize, Clone)] #[derive(Reflect, Serialize, Deserialize, Clone)]
#[reflect(opaque)] #[reflect(opaque)]
-23
View File
@@ -9,7 +9,6 @@ use bevy_ggrs::{PlayerInputs, Rollback};
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
use strum_macros::EnumIter; use strum_macros::EnumIter;
use crate::fighter::FighterFacingDirection;
use crate::fighter::attack::AttackKind; use crate::fighter::attack::AttackKind;
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
use crate::input::FighterInputFrame; use crate::input::FighterInputFrame;
@@ -55,26 +54,6 @@ impl FighterAttackCommandType {
FighterAttackCommandType::TiltAttackRight => AttackKind::ForwardTilt, FighterAttackCommandType::TiltAttackRight => AttackKind::ForwardTilt,
} }
} }
pub fn to_attack_kind_airborne(&self, facing_direction: &FighterFacingDirection) -> AttackKind {
match self {
FighterAttackCommandType::Neutral => AttackKind::NeutralAir,
FighterAttackCommandType::SmashAttackUp | FighterAttackCommandType::TiltAttackUp => AttackKind::UpAir,
FighterAttackCommandType::SmashAttackDown | FighterAttackCommandType::TiltAttackDown => AttackKind::DownAir,
FighterAttackCommandType::SmashAttackLeft | FighterAttackCommandType::TiltAttackLeft => {
match facing_direction {
FighterFacingDirection::Left => AttackKind::ForwardAir,
FighterFacingDirection::Right => AttackKind::BackAir,
}
},
FighterAttackCommandType::SmashAttackRight | FighterAttackCommandType::TiltAttackRight => {
match facing_direction {
FighterFacingDirection::Left => AttackKind::BackAir,
FighterFacingDirection::Right => AttackKind::ForwardAir,
}
},
}
}
} }
/// Frames of life remaining for each buffered command. Indexed by /// Frames of life remaining for each buffered command. Indexed by
@@ -254,8 +233,6 @@ pub fn postprocess_input(
input.clear_command(FighterCommands::SmashMoveUp); input.clear_command(FighterCommands::SmashMoveUp);
input.clear_command(FighterCommands::SmashMoveDown); input.clear_command(FighterCommands::SmashMoveDown);
input.frames_in_y_flick_deadzone = 0xFF;
let command_to_set = if input.current_frame.movement.y > 0.0 { let command_to_set = if input.current_frame.movement.y > 0.0 {
FighterCommands::SmashMoveUp FighterCommands::SmashMoveUp
} else { } else {
+10 -74
View File
@@ -1,8 +1,6 @@
use std::panic::{self, AssertUnwindSafe}; use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use bevy::prelude::*; use bevy::prelude::*;
use gc_adapter_neo::{Controller, GcAdapter}; use gc_adapter_neo::{Controller, GcAdapter};
@@ -57,11 +55,10 @@ impl GcPortState {
/// Shared, `Send + Sync` handle to the latest snapshot from the poll /// Shared, `Send + Sync` handle to the latest snapshot from the poll
/// thread. Safe to store as an ordinary [`Resource`] (no `NonSend` needed) /// thread. Safe to store as an ordinary [`Resource`] (no `NonSend` needed)
/// because the `GcAdapter` itself never leaves its thread. /// because the `GcAdapter` itself never leaves its thread.
#[derive(Resource)] #[derive(Resource, Clone)]
pub struct GcAdapterSnapshot { pub struct GcAdapterSnapshot {
ports: Arc<Mutex<[GcPortState; 4]>>, ports: Arc<Mutex<[GcPortState; 4]>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
poll_thread: Option<JoinHandle<()>>,
} }
impl GcAdapterSnapshot { impl GcAdapterSnapshot {
@@ -72,38 +69,14 @@ impl GcAdapterSnapshot {
impl Drop for GcAdapterSnapshot { impl Drop for GcAdapterSnapshot {
fn drop(&mut self) { fn drop(&mut self) {
self.shutdown.store(true, Ordering::Release); // Only actually signals shutdown once the last clone (this resource
// is never cloned in practice, but be defensive) is dropped.
if let Some(poll_thread) = self.poll_thread.take() { if Arc::strong_count(&self.shutdown) <= 1 {
if let Err(payload) = poll_thread.join() { self.shutdown.store(true, Ordering::Relaxed);
error!(
"GameCube Controller Adapter poll thread panicked during shutdown: {payload:?}"
);
}
} }
} }
} }
const INITIAL_ERROR_BACKOFF: Duration = Duration::from_millis(1);
const MAX_ERROR_BACKOFF: Duration = Duration::from_millis(100);
fn next_error_backoff(backoff: Duration) -> Duration {
backoff.saturating_mul(2).min(MAX_ERROR_BACKOFF)
}
/// Wait for a retry while still allowing the owner to shut the poll thread down promptly.
fn wait_for_retry(shutdown: &AtomicBool, delay: Duration) -> bool {
let deadline = Instant::now() + delay;
while !shutdown.load(Ordering::Acquire) {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return true;
}
std::thread::sleep(remaining.min(Duration::from_millis(10)));
}
false
}
/// Connects to the first GameCube adapter found on the USB bus and starts /// Connects to the first GameCube adapter found on the USB bus and starts
/// polling it on a dedicated thread. Returns `None` (after logging a /// polling it on a dedicated thread. Returns `None` (after logging a
/// warning) if no adapter is plugged in /// warning) if no adapter is plugged in
@@ -121,7 +94,7 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
let thread_ports = ports.clone(); let thread_ports = ports.clone();
let thread_shutdown = shutdown.clone(); let thread_shutdown = shutdown.clone();
let poll_thread = std::thread::Builder::new() std::thread::Builder::new()
.name("gc-adapter-poll".into()) .name("gc-adapter-poll".into())
.spawn(move || { .spawn(move || {
// Flush whatever the adapter had buffered before we started // Flush whatever the adapter had buffered before we started
@@ -134,27 +107,17 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
let mut rate_window_start = std::time::Instant::now(); let mut rate_window_start = std::time::Instant::now();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let mut rate_window_count: u32 = 0; let mut rate_window_count: u32 = 0;
let mut error_backoff = INITIAL_ERROR_BACKOFF;
while !thread_shutdown.load(Ordering::Acquire) { while !thread_shutdown.load(Ordering::Relaxed) {
let result = panic::catch_unwind(AssertUnwindSafe(|| adapter.read_controllers())); let result = panic::catch_unwind(AssertUnwindSafe(|| adapter.read_controllers()));
let controllers = match result { let controllers = match result {
Ok(Ok(controllers)) => { Ok(Ok(controllers)) => controllers,
error_backoff = INITIAL_ERROR_BACKOFF;
controllers
}
// Ignore this, for some reason we get a bunch of these when we start reading // Ignore this, for some reason we get a bunch of these when we start reading
Ok(Err( Ok(Err(
gc_adapter_neo::AdapterError::Parse(_) gc_adapter_neo::AdapterError::Parse(_)
| gc_adapter_neo::AdapterError::ShortRead(_), | gc_adapter_neo::AdapterError::ShortRead(_),
)) => { )) => continue,
if !wait_for_retry(&thread_shutdown, error_backoff) {
break;
}
error_backoff = next_error_backoff(error_backoff);
continue;
}
Ok(Err(gc_adapter_neo::AdapterError::Usb(err))) => { Ok(Err(gc_adapter_neo::AdapterError::Usb(err))) => {
error!( error!(
"GameCube Controller Adapter USB read failed ({err:?}); stopping \ "GameCube Controller Adapter USB read failed ({err:?}); stopping \
@@ -197,32 +160,5 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
}) })
.expect("failed to spawn gc-adapter poll thread"); .expect("failed to spawn gc-adapter poll thread");
Ok(GcAdapterSnapshot { Ok(GcAdapterSnapshot { ports, shutdown })
ports,
shutdown,
poll_thread: Some(poll_thread),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_backoff_is_capped() {
let mut backoff = INITIAL_ERROR_BACKOFF;
for _ in 0..16 {
backoff = next_error_backoff(backoff);
}
assert_eq!(backoff, MAX_ERROR_BACKOFF);
assert_eq!(next_error_backoff(MAX_ERROR_BACKOFF), MAX_ERROR_BACKOFF);
}
#[test]
fn retry_wait_stops_when_shutdown_is_requested() {
let shutdown = AtomicBool::new(true);
assert!(!wait_for_retry(&shutdown, Duration::from_secs(1)));
}
} }
+13 -19
View File
@@ -28,7 +28,7 @@ pub enum LocalInputSource {
Gamepad(Entity), Gamepad(Entity),
} }
/// Persistent mapping from physical/menu controller slots to input sources. /// Maps an input source to a player index
#[derive(Resource, Clone, Debug, Default, Deref, DerefMut)] #[derive(Resource, Clone, Debug, Default, Deref, DerefMut)]
pub struct LocalInputAssignments(pub Vec<(usize, LocalInputSource)>); pub struct LocalInputAssignments(pub Vec<(usize, LocalInputSource)>);
@@ -50,16 +50,6 @@ impl LocalInputAssignments {
} }
} }
/// Match-local mapping from dense GGRS handles to physical input sources.
#[derive(Resource, Clone, Debug, Default, Deref, DerefMut)]
pub struct MatchInputAssignments(pub Vec<Option<LocalInputSource>>);
impl MatchInputAssignments {
pub fn get_for_handle(&self, handle: usize) -> Option<LocalInputSource> {
self.0.get(handle).copied().flatten()
}
}
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
use crate::math::int::FGi32; use crate::math::int::FGi32;
use crate::netcode::GGRSCfg; use crate::netcode::GGRSCfg;
@@ -126,12 +116,13 @@ impl Plugin for FighterInputPlugin {
binding: GamepadBinding::Button(GamepadButton::North), binding: GamepadBinding::Button(GamepadButton::North),
action: InputMapAction::Jump, action: InputMapAction::Jump,
}, },
// shield/airdodge -- both shoulder buttons, Melee-style
GamepadInputMapElement { GamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::LeftTrigger2), binding: GamepadBinding::Button(GamepadButton::LeftTrigger),
action: InputMapAction::Shield, action: InputMapAction::Shield,
}, },
GamepadInputMapElement { GamepadInputMapElement {
binding: GamepadBinding::Button(GamepadButton::RightTrigger2), binding: GamepadBinding::Button(GamepadButton::RightTrigger),
action: InputMapAction::Shield, action: InputMapAction::Shield,
}, },
// attack // attack
@@ -254,17 +245,20 @@ fn read_local_inputs(
input_map: Res<BaseInputMap>, input_map: Res<BaseInputMap>,
gc_ports: Res<gamecube::GcPorts>, gc_ports: Res<gamecube::GcPorts>,
gamepads: Query<&Gamepad>, gamepads: Query<&Gamepad>,
assignments: Res<MatchInputAssignments>, assignments: Res<LocalInputAssignments>,
local_players: Res<LocalPlayers>, local_players: Res<LocalPlayers>,
game_settings: Res<GameSettings>, game_settings: Res<GameSettings>,
) { ) {
let mut local_inputs = HashMap::new(); let mut local_inputs = HashMap::new();
for handle in &local_players.0 { for handle in &local_players.0 {
let assigned = assignments.get_for_handle(*handle); let assigned = assignments
.0
.iter()
.find(|(player_handle, _)| player_handle == handle);
let gamepad = match assigned { let gamepad = match assigned {
Some(LocalInputSource::Gamepad(entity)) => gamepads.get(entity).ok(), Some((_, LocalInputSource::Gamepad(entity))) => gamepads.get(*entity).ok(),
Some(LocalInputSource::Keyboard) => None, Some((_, LocalInputSource::Keyboard)) => None,
None => gc_ports None => gc_ports
.entities .entities
.get(*handle) .get(*handle)
@@ -274,8 +268,8 @@ fn read_local_inputs(
}; };
let mut input_frame = match assigned { let mut input_frame = match assigned {
Some(LocalInputSource::Keyboard) => keyboard::sample_keyboard(&key, &input_map), Some((_, LocalInputSource::Keyboard)) => keyboard::sample_keyboard(&key, &input_map),
Some(LocalInputSource::Gamepad(_)) => gamepad Some((_, LocalInputSource::Gamepad(_))) => gamepad
.map(|pad| gamepad::sample_gamepad(pad, &input_map)) .map(|pad| gamepad::sample_gamepad(pad, &input_map))
.unwrap_or_default(), .unwrap_or_default(),
None => FighterInputFrame::default(), None => FighterInputFrame::default(),
+20 -30
View File
@@ -21,8 +21,6 @@
//! save, click Rebuild in jackdaw (or run `jd build`) and it //! save, click Rebuild in jackdaw (or run `jd build`) and it
//! appears in `Add Component`. No registration code is needed. //! appears in `Add Component`. No registration code is needed.
#![recursion_limit = "512"]
use bevy::{ use bevy::{
asset::processor::{AssetProcessor, ProcessorState}, asset::processor::{AssetProcessor, ProcessorState},
prelude::*, prelude::*,
@@ -34,23 +32,22 @@ use clap::Parser;
use ron_asset_manager::RonAssetPlugin; use ron_asset_manager::RonAssetPlugin;
use crate::{ use crate::{
camera::MatchCameraPlugin, fighter::manifest::FighterManifest, game_settings::GameSettings, camera::MatchCameraPlugin,
match_runtime::MatchRuntimePlugin, materials::ShinesparkMaterialPlugin, fighter::manifest::FighterManifest,
menus::style::FGUiStyle, schedule::GameplaySchedulePlugin, stage::manifest::StageManifest, game_settings::GameSettings,
menus::style::FGUiStyle,
schedule::GameplaySchedulePlugin,
stage::manifest::StageManifest,
ui::InGameUIPlugin, ui::InGameUIPlugin,
}; };
mod args; mod args;
pub mod blend_asset;
pub mod camera; pub mod camera;
pub mod debug_tools; pub mod debug_tools;
pub mod fighter; pub mod fighter;
pub mod filled_gizmos;
pub mod game_settings; pub mod game_settings;
pub mod input; pub mod input;
pub mod match_loading; pub mod match_loading;
pub mod match_runtime;
pub mod materials;
mod math; mod math;
pub mod menus; pub mod menus;
pub mod netcode; pub mod netcode;
@@ -79,7 +76,6 @@ pub enum AppState {
LoadingMatch, LoadingMatch,
PreparingMatch, PreparingMatch,
InMatch, InMatch,
MatchResults,
MatchDisconnected, MatchDisconnected,
MatchLoadFailed, MatchLoadFailed,
} }
@@ -96,7 +92,6 @@ pub struct GameAssetPlugin;
impl Plugin for GameAssetPlugin { impl Plugin for GameAssetPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_plugins(( app.add_plugins((
blend_asset::BlendAssetProcessingPlugin,
RonAssetPlugin::<FighterManifest>::create("fighter.ron"), RonAssetPlugin::<FighterManifest>::create("fighter.ron"),
RonAssetPlugin::<StageManifest>::create("stage.ron"), RonAssetPlugin::<StageManifest>::create("stage.ron"),
RonAssetPlugin::<GameSettings>::create("ron"), RonAssetPlugin::<GameSettings>::create("ron"),
@@ -112,25 +107,20 @@ impl Plugin for GamePlugin {
// game-level content and configuration. // game-level content and configuration.
app.init_state::<AppState>() app.init_state::<AppState>()
.add_plugins(( .add_plugins((
( SettingsPlugin::new("moe.eirteam.shinespark.debug_settings"),
SettingsPlugin::new("moe.eirteam.shinespark.debug_settings"), // Brings up GGRS (and with it `GgrsSchedule`), so it goes first.
// Brings up GGRS (and with it `GgrsSchedule`), so it goes first. netcode::FighterNetcodePlugin::default(),
netcode::FighterNetcodePlugin::default(), GameplaySchedulePlugin,
GameplaySchedulePlugin, input::FighterInputPlugin,
MatchRuntimePlugin, replay::ReplayPlugin,
input::FighterInputPlugin, debug_tools::DebugToolsPlugin,
replay::ReplayPlugin, fighter::FighterPlugin,
debug_tools::DebugToolsPlugin, fighter::baked_animation::BakedAnimationPlugin,
filled_gizmos::FilledGizmosPlugin, scripting::importer::FighterScriptImportPlugin,
fighter::FighterPlugin, stage::StagePlugin,
fighter::baked_animation::BakedAnimationPlugin, MatchCameraPlugin,
scripting::importer::FighterScriptImportPlugin, GameAssetPlugin,
stage::StagePlugin, InGameUIPlugin,
MatchCameraPlugin,
GameAssetPlugin,
InGameUIPlugin,
),
ShinesparkMaterialPlugin,
)) ))
.add_plugins(menus::MenuPlugin) .add_plugins(menus::MenuPlugin)
.add_loading_state( .add_loading_state(
+6 -11
View File
@@ -1,7 +1,6 @@
use bevy::asset::AssetMode::Processed; use bevy::asset::AssetMode::Processed;
#[cfg(feature = "debug_features")] #[cfg(feature = "debug_features")]
use bevy::camera_controller::free_camera::FreeCameraPlugin; use bevy::camera_controller::free_camera::FreeCameraPlugin;
use bevy::gltf::GltfPlugin;
use bevy::image::{ImageAddressMode, ImagePlugin, ImageSamplerDescriptor}; use bevy::image::{ImageAddressMode, ImagePlugin, ImageSamplerDescriptor};
use bevy::prelude::*; use bevy::prelude::*;
#[cfg(feature = "debug_features")] #[cfg(feature = "debug_features")]
@@ -13,18 +12,14 @@ fn main() -> AppExit {
// A `Repeat` sampler so the tileable materials authored in the // A `Repeat` sampler so the tileable materials authored in the
// editor render the same way at runtime; Bevy's default // editor render the same way at runtime; Bevy's default
// `ClampToEdge` smears texture edges on brush geometry. // `ClampToEdge` smears texture edges on brush geometry.
let default_sampler = ImageSamplerDescriptor::linear()
.set_address_mode(ImageAddressMode::Repeat)
.set_anisotropic_filter(16)
.clone();
let default_plugins = DefaultPlugins let default_plugins = DefaultPlugins
.set(ImagePlugin { .set(ImagePlugin {
default_sampler: default_sampler.clone(), default_sampler: ImageSamplerDescriptor {
}) address_mode_u: ImageAddressMode::Repeat,
.set(GltfPlugin { address_mode_v: ImageAddressMode::Repeat,
default_sampler, address_mode_w: ImageAddressMode::Repeat,
..default() ..ImageSamplerDescriptor::linear()
},
}) })
.set(AssetPlugin { .set(AssetPlugin {
mode: Processed, mode: Processed,
+55 -295
View File
@@ -8,91 +8,40 @@ use bevy_ggrs::prelude::Session;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
AppState, args::Args, fighter::{ args::Args,
FighterId, FighterRoster, fighter::{
animation::AnimKind, animation::AnimKind,
attack::{AttackKind, FighterAttackScriptAssets}, attack::{AttackKind, FighterAttackScriptAssets},
baked_animation::{BAKED_ANIMATION_FPS, BakedFighterAnimations}, baked_animation::{BakedFighterAnimations, BAKED_ANIMATION_FPS},
manifest::{FighterManifest, FighterManifestRegistry}, manifest::{FighterManifest, FighterManifestRegistry},
spawn_fighter, spawn_fighter,
visual::FighterAnimations, visual::FighterAnimations,
}, input::{LocalInputAssignments, LocalInputSource, MatchInputAssignments}, match_runtime::MatchState, netcode::{ FighterId, FighterRoster,
},
input::{LocalInputAssignments, LocalInputSource},
math::{int::FGi32, vec::FGVec2},
netcode::{
session::{create_session, ConnectionStatus, SessionConfig},
GGRSCfg, GGRSCfg,
session::{ConnectionStatus, SessionConfig, create_session}, },
}, scripting::FighterAttackScript, stage::{ scripting::FighterAttackScript,
stage::{
self, self,
manifest::{StageId, StageManifest, StageManifestRegistry}, manifest::{StageId, StageManifest, StageManifestRegistry},
}, },
AppState,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MatchPlayer { pub struct MatchPlayer {
/// Dense index used by GGRS and rollback-simulated player state.
pub handle: usize, pub handle: usize,
/// Physical controller port selected in the menu.
pub controller_port: usize,
pub fighter: FighterId, pub fighter: FighterId,
} }
#[derive(Resource, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Resource, Debug, Clone, PartialEq, Eq)]
pub struct MatchConfig { pub struct PendingMatch {
pub stage: StageId, pub stage: StageId,
pub players: Vec<MatchPlayer>, pub players: Vec<MatchPlayer>,
pub rules: MatchRules,
}
impl MatchConfig {
/// Maps a GGRS handle to the controller port used by gameplay.
///
/// Multiplayer uses the stable, dense GGRS handle as the port. A
/// single-player match preserves the physical controller port selected in
/// the menu (for example, port 3 remains port 3).
pub fn controller_port_for_handle(&self, handle: usize) -> Option<usize> {
let player = self.players.iter().find(|player| player.handle == handle)?;
Some(if self.players.len() > 1 {
handle
} else {
player.controller_port
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MatchRules {
Stock {
starting_stocks: u8,
time_limit_frames: Option<u32>,
},
Timed {
time_limit_frames: u32,
},
}
impl Default for MatchRules {
fn default() -> Self {
Self::Stock {
starting_stocks: 3,
time_limit_frames: None,
}
}
}
impl MatchRules {
fn validate(&self) -> Result<(), &'static str> {
match self {
Self::Stock {
starting_stocks: 0, ..
} => Err("stock matches must start with at least one stock"),
Self::Stock {
time_limit_frames: Some(0),
..
} => Err("stock match time limits must be greater than zero frames"),
Self::Timed {
time_limit_frames: 0,
} => Err("timed match limits must be greater than zero frames"),
_ => Ok(()),
}
}
} }
pub struct SelectedFighterAssets { pub struct SelectedFighterAssets {
@@ -110,7 +59,7 @@ pub struct MatchAssets {
impl MatchAssets { impl MatchAssets {
fn requested(world: &World) -> Self { fn requested(world: &World) -> Self {
let config = world.resource::<MatchConfig>(); let request = world.resource::<PendingMatch>();
let asset_server = world.resource::<AssetServer>(); let asset_server = world.resource::<AssetServer>();
let registry = world.resource::<FighterManifestRegistry>(); let registry = world.resource::<FighterManifestRegistry>();
let stage_registry = world.resource::<StageManifestRegistry>(); let stage_registry = world.resource::<StageManifestRegistry>();
@@ -118,7 +67,7 @@ impl MatchAssets {
let stage_manifests = world.resource::<Assets<StageManifest>>(); let stage_manifests = world.resource::<Assets<StageManifest>>();
let stage_handle = stage_registry let stage_handle = stage_registry
.get(config.stage) .get(request.stage)
.expect("Startup validation guarantees every stage has a manifest"); .expect("Startup validation guarantees every stage has a manifest");
let stage_manifest = stage_manifests let stage_manifest = stage_manifests
.get(&stage_handle) .get(&stage_handle)
@@ -126,7 +75,7 @@ impl MatchAssets {
Self { Self {
stage: asset_server.load(stage_manifest.model_path.clone()), stage: asset_server.load(stage_manifest.model_path.clone()),
fighters: config fighters: request
.players .players
.iter() .iter()
.map(|player| { .map(|player| {
@@ -192,10 +141,10 @@ pub struct MatchRoot;
pub fn initiate_match( pub fn initiate_match(
commands: &mut Commands, commands: &mut Commands,
next_state: &mut NextState<AppState>, next_state: &mut NextState<AppState>,
config: MatchConfig, request: PendingMatch,
) { ) {
commands.remove_resource::<crate::netcode::session::MatchDisconnected>(); commands.remove_resource::<crate::netcode::session::MatchDisconnected>();
commands.insert_resource(config); commands.insert_resource(request);
next_state.set(AppState::LoadingMatch); next_state.set(AppState::LoadingMatch);
} }
@@ -217,9 +166,12 @@ pub fn initiate_default_match(
if let Some(path) = &args.play_replay { if let Some(path) = &args.play_replay {
match crate::replay::load(path) { match crate::replay::load(path) {
Ok(replay) => { Ok(replay) => {
let config = replay.match_config.clone(); let request = PendingMatch {
stage: replay.match_config.stage,
players: replay.match_config.players.clone(),
};
commands.insert_resource(crate::replay::LoadedReplay(replay)); commands.insert_resource(crate::replay::LoadedReplay(replay));
initiate_match(&mut commands, &mut next_state, config); initiate_match(&mut commands, &mut next_state, request);
} }
Err(error) => { Err(error) => {
error!("Replay load failed: {error}"); error!("Replay load failed: {error}");
@@ -239,7 +191,6 @@ pub fn initiate_default_match(
let players = (0..args.players) let players = (0..args.players)
.map(|handle| MatchPlayer { .map(|handle| MatchPlayer {
handle, handle,
controller_port: handle,
fighter: FighterId::TestFighter, fighter: FighterId::TestFighter,
}) })
.collect(); .collect();
@@ -264,18 +215,16 @@ pub fn initiate_default_match(
initiate_match( initiate_match(
&mut commands, &mut commands,
&mut next_state, &mut next_state,
MatchConfig { PendingMatch {
stage: StageId::TestStage, stage: StageId::TestStage,
players, players,
rules: MatchRules::default(),
}, },
); );
} }
pub fn prepare_match( pub fn prepare_match(
mut commands: Commands, mut commands: Commands,
config: Res<MatchConfig>, request: Res<PendingMatch>,
input_assignments: Res<LocalInputAssignments>,
assets: Res<MatchAssets>, assets: Res<MatchAssets>,
gltfs: Res<Assets<Gltf>>, gltfs: Res<Assets<Gltf>>,
baked_animations: Res<Assets<BakedFighterAnimations>>, baked_animations: Res<Assets<BakedFighterAnimations>>,
@@ -287,34 +236,15 @@ pub fn prepare_match(
args: Res<Args>, args: Res<Args>,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
) { ) {
if !valid_player_handles(&config.players) { if !valid_player_handles(&request.players) {
fail_match( fail_match(
&mut next_state, &mut next_state,
"player handles must be dense, unique, and non-empty", "player handles must be unique and cover 0..player_count",
); );
return; return;
} }
if let Err(error) = config.rules.validate() { if assets.fighters.len() != request.players.len() {
fail_match(&mut next_state, error);
return;
}
let stage_handle = stage_registry
.get(config.stage)
.expect("Initialization check should ensure all stages exist");
let stage_manifest = stage_manifests
.get(&stage_handle)
.expect("Initialization check should ensure stage manifests are kept alive");
let spawn_positions = match stage_manifest.neutral_spawn_positions(config.players.len()) {
Ok(positions) => positions,
Err(error) => {
fail_match(&mut next_state, &error);
return;
}
};
if assets.fighters.len() != config.players.len() {
fail_match( fail_match(
&mut next_state, &mut next_state,
"loaded character assets do not match the requested player slots", "loaded character assets do not match the requested player slots",
@@ -363,15 +293,6 @@ pub fn prepare_match(
); );
return; return;
} }
let ecb_bone_indices = baked.bone_names.iter().enumerate().filter_map(|(idx, name)| {
if manifest.ecb_bones.contains(name) {
Some(idx)
} else {
None
}
}).collect::<Vec<_>>();
if let Err(error) = manifest.attributes.validate() { if let Err(error) = manifest.attributes.validate() {
fail_match( fail_match(
&mut next_state, &mut next_state,
@@ -472,12 +393,10 @@ pub fn prepare_match(
attack_scripts, attack_scripts,
manifest_handle, manifest_handle,
manifest.model_scale, manifest.model_scale,
ecb_bone_indices,
)); ));
} }
let session_player_count = player_count_for_handles(&config.players); let session_config = match SessionConfig::from_args(&args, request.players.len()) {
let session_config = match SessionConfig::from_args(&args, session_player_count) {
Ok(config) => config, Ok(config) => config,
Err(error) => { Err(error) => {
fail_match( fail_match(
@@ -487,7 +406,7 @@ pub fn prepare_match(
return; return;
} }
}; };
let session = match create_session(&args, session_player_count) { let session = match create_session(&args, request.players.len()) {
Ok(session) => session, Ok(session) => session,
Err(error) => { Err(error) => {
fail_match( fail_match(
@@ -498,13 +417,14 @@ pub fn prepare_match(
} }
}; };
commands.insert_resource(create_match_input_assignments(
&input_assignments,
&config.players,
));
let root = commands.spawn((Name::new("Match"), MatchRoot)).id(); let root = commands.spawn((Name::new("Match"), MatchRoot)).id();
let stage_handle = stage_registry
.get(request.stage)
.expect("Initialization check should ensure all stages exist");
let stage_manifest = stage_manifests
.get(&stage_handle)
.expect("Initialization check should ensure stage manifests are kept alive");
stage::spawn_stage( stage::spawn_stage(
&mut commands, &mut commands,
root, root,
@@ -513,30 +433,24 @@ pub fn prepare_match(
stage_manifest.camera.clone(), stage_manifest.camera.clone(),
); );
let mut fighter_roster = Vec::with_capacity(config.players.len()); let mut fighter_roster = Vec::with_capacity(request.players.len());
for (index, (player, (scene, animations, attack_scripts, manifest_handle, model_scale))) in
for (spawn_index, (player, (scene, animations, attack_scripts, manifest_handle, model_scale, ecb_bone_indices))) in request.players.iter().zip(prepared).enumerate()
config.players.iter().zip(prepared).enumerate()
{ {
let spawn_point = spawn_positions[spawn_index]; let spawn_x = (index as i32 * 2 + 1 - request.players.len() as i32) * 5;
fighter_roster.push(spawn_fighter( fighter_roster.push(spawn_fighter(
&mut commands, &mut commands,
player.handle, player.handle,
config FGVec2::new(FGi32::from_num(spawn_x), FGi32::lit("12.5")),
.controller_port_for_handle(player.handle)
.expect("validated match config contains every player handle"),
spawn_point,
manifest_handle.clone(), manifest_handle.clone(),
model_scale, model_scale,
animations, animations,
attack_scripts, attack_scripts,
WorldAssetRoot(scene), WorldAssetRoot(scene),
ecb_bone_indices
)); ));
} }
commands.insert_resource(FighterRoster(fighter_roster)); commands.insert_resource(FighterRoster(fighter_roster));
commands.insert_resource(MatchState::from_config(&config));
commands.insert_resource(session_config); commands.insert_resource(session_config);
commands.insert_resource(ConnectionStatus::default()); commands.insert_resource(ConnectionStatus::default());
commands.insert_resource::<Session<GGRSCfg>>(session); commands.insert_resource::<Session<GGRSCfg>>(session);
@@ -549,30 +463,12 @@ fn fail_match(next_state: &mut NextState<AppState>, message: &str) {
} }
fn valid_player_handles(players: &[MatchPlayer]) -> bool { fn valid_player_handles(players: &[MatchPlayer]) -> bool {
if players.is_empty() { let mut handles = players
return false; .iter()
} .map(|player| player.handle)
.collect::<Vec<_>>();
let mut handles = players.iter().map(|player| player.handle).collect::<Vec<_>>();
handles.sort_unstable(); handles.sort_unstable();
handles.into_iter().eq(0..players.len()) handles == (0..players.len()).collect::<Vec<_>>()
}
fn player_count_for_handles(players: &[MatchPlayer]) -> usize {
players.len()
}
fn create_match_input_assignments(
assignments: &LocalInputAssignments,
players: &[MatchPlayer],
) -> MatchInputAssignments {
let mut match_assignments = vec![None; players.len()];
for player in players {
match_assignments[player.handle] = assignments
.get_from_slot(player.controller_port)
.copied();
}
MatchInputAssignments(match_assignments)
} }
pub fn cleanup_match( pub fn cleanup_match(
@@ -591,9 +487,7 @@ pub fn cleanup_match(
commands.remove_resource::<ConnectionStatus>(); commands.remove_resource::<ConnectionStatus>();
commands.remove_resource::<stage::line::StageCollision>(); commands.remove_resource::<stage::line::StageCollision>();
commands.remove_resource::<MatchAssets>(); commands.remove_resource::<MatchAssets>();
commands.remove_resource::<MatchConfig>(); commands.remove_resource::<PendingMatch>();
commands.remove_resource::<MatchState>();
commands.remove_resource::<MatchInputAssignments>();
commands.remove_resource::<FighterRoster>(); commands.remove_resource::<FighterRoster>();
} }
@@ -602,28 +496,25 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn match_config_keeps_duplicate_fighter_slots() { fn match_request_keeps_duplicate_fighter_slots() {
let config = MatchConfig { let request = PendingMatch {
stage: StageId::TestStage, stage: StageId::TestStage,
players: vec![ players: vec![
MatchPlayer { MatchPlayer {
handle: 0, handle: 0,
controller_port: 0,
fighter: FighterId::TestFighter, fighter: FighterId::TestFighter,
}, },
MatchPlayer { MatchPlayer {
handle: 1, handle: 1,
controller_port: 1,
fighter: FighterId::TestFighter, fighter: FighterId::TestFighter,
}, },
], ],
rules: MatchRules::default(),
}; };
assert_eq!(config.players.len(), 2); assert_eq!(request.players.len(), 2);
assert_eq!(config.players[0].handle, 0); assert_eq!(request.players[0].handle, 0);
assert_eq!(config.players[1].handle, 1); assert_eq!(request.players[1].handle, 1);
assert!(valid_player_handles(&config.players)); assert!(valid_player_handles(&request.players));
} }
#[test] #[test]
@@ -631,12 +522,10 @@ mod tests {
let players = [ let players = [
MatchPlayer { MatchPlayer {
handle: 0, handle: 0,
controller_port: 0,
fighter: FighterId::TestFighter, fighter: FighterId::TestFighter,
}, },
MatchPlayer { MatchPlayer {
handle: 0, handle: 0,
controller_port: 1,
fighter: FighterId::TestFighter, fighter: FighterId::TestFighter,
}, },
]; ];
@@ -644,135 +533,6 @@ mod tests {
assert!(!valid_player_handles(&players)); assert!(!valid_player_handles(&players));
} }
#[test]
fn player_handles_must_not_have_gaps() {
let players = [
MatchPlayer {
handle: 0,
controller_port: 0,
fighter: FighterId::TestFighter,
},
MatchPlayer {
handle: 2,
controller_port: 3,
fighter: FighterId::TestFighter,
},
];
assert!(!valid_player_handles(&players));
assert_eq!(player_count_for_handles(&players), 2);
}
#[test]
fn controller_ports_use_ggrs_handles_only_in_multiplayer() {
let single_player = MatchConfig {
stage: StageId::TestStage,
players: vec![MatchPlayer {
handle: 0,
controller_port: 3,
fighter: FighterId::TestFighter,
}],
rules: MatchRules::default(),
};
assert_eq!(single_player.controller_port_for_handle(0), Some(3));
let multiplayer = MatchConfig {
stage: StageId::TestStage,
players: vec![
MatchPlayer {
handle: 0,
controller_port: 0,
fighter: FighterId::TestFighter,
},
MatchPlayer {
handle: 1,
controller_port: 3,
fighter: FighterId::TestFighter,
},
],
rules: MatchRules::default(),
};
assert_eq!(multiplayer.controller_port_for_handle(0), Some(0));
assert_eq!(multiplayer.controller_port_for_handle(1), Some(1));
}
#[test]
fn match_input_assignments_map_ports_to_ggrs_handles_without_mutating_ports() {
let players = [
MatchPlayer {
handle: 0,
controller_port: 0,
fighter: FighterId::TestFighter,
},
MatchPlayer {
handle: 1,
controller_port: 3,
fighter: FighterId::TestFighter,
},
];
let assignments = LocalInputAssignments(vec![
(0, LocalInputSource::Keyboard),
(3, LocalInputSource::Gamepad(Entity::PLACEHOLDER)),
]);
let match_assignments = create_match_input_assignments(&assignments, &players);
assert_eq!(match_assignments.get_for_handle(0), Some(LocalInputSource::Keyboard));
assert_eq!(
match_assignments.get_for_handle(1),
Some(LocalInputSource::Gamepad(Entity::PLACEHOLDER))
);
assert_eq!(assignments[0].0, 0);
assert_eq!(assignments[1].0, 3);
}
#[test]
fn match_rules_reject_zero_limits() {
assert!(
MatchRules::Stock {
starting_stocks: 0,
time_limit_frames: None,
}
.validate()
.is_err()
);
assert!(
MatchRules::Stock {
starting_stocks: 3,
time_limit_frames: Some(0),
}
.validate()
.is_err()
);
assert!(
MatchRules::Timed {
time_limit_frames: 0,
}
.validate()
.is_err()
);
}
#[test]
fn match_rules_accept_valid_modes() {
assert!(MatchRules::default().validate().is_ok());
assert!(
MatchRules::Stock {
starting_stocks: 3,
time_limit_frames: Some(60),
}
.validate()
.is_ok()
);
assert!(
MatchRules::Timed {
time_limit_frames: 60,
}
.validate()
.is_ok()
);
}
#[test] #[test]
fn match_loading_waits_for_attack_scripts() { fn match_loading_waits_for_attack_scripts() {
let mut attack_scripts = HashMap::new(); let mut attack_scripts = HashMap::new();
-318
View File
@@ -1,318 +0,0 @@
//! Rollback-simulated state for an active match.
use bevy::input::InputSystems;
use bevy::prelude::*;
use bevy_ggrs::{ConfirmedFrameCount, RollbackFrameCount, RunGgrsSystems, prelude::*};
use crate::{
AppState,
match_loading::{MatchConfig, MatchRules},
netcode::session::SessionConfig,
};
#[derive(Resource, Clone, Debug, Hash, PartialEq, Eq)]
pub struct MatchState {
pub elapsed_frames: u32,
/// Indexed by the player's session handle.
pub players: Vec<PlayerMatchState>,
/// The rollback frame on which the match first finished.
pub finished_at_frame: Option<i32>,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct PlayerMatchState {
pub stocks_remaining: Option<u8>,
pub score: i32,
pub deaths: u32,
}
#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct PauseState {
pub paused: bool,
}
impl MatchState {
pub fn from_config(config: &MatchConfig) -> Self {
let stocks_remaining = match &config.rules {
MatchRules::Stock {
starting_stocks, ..
} => Some(*starting_stocks),
MatchRules::Timed { .. } => None,
};
Self {
elapsed_frames: 0,
players: (0..config.players.len())
.map(|_| PlayerMatchState {
stocks_remaining,
score: 0,
deaths: 0,
})
.collect(),
finished_at_frame: None,
}
}
pub fn is_match_finished(&self, config: &MatchConfig) -> bool {
let time_limit = match config.rules {
MatchRules::Stock {
starting_stocks: _,
time_limit_frames,
} => time_limit_frames,
MatchRules::Timed { time_limit_frames } => Some(time_limit_frames),
};
if let Some(time_limit) = time_limit {
if self.elapsed_frames >= time_limit {
return true;
}
}
if config.players.len() > 1
&& let MatchRules::Stock {
starting_stocks: _,
time_limit_frames: _,
} = config.rules
{
let alive_fighter_count = self
.players
.iter()
.filter(|fighter| {
fighter
.stocks_remaining
.map(|stocks| stocks)
.unwrap_or_default()
> 0
})
.count();
if alive_fighter_count <= 1 {
return true;
}
}
false
}
fn advance_frame(&mut self, config: &MatchConfig, frame: i32) {
if self.finished_at_frame.is_some() {
return;
}
self.elapsed_frames = self.elapsed_frames.saturating_add(1);
if self.is_match_finished(config) {
self.finished_at_frame = Some(frame);
}
}
fn finish_is_confirmed(&self, confirmed_frame: i32) -> bool {
self.finished_at_frame
.is_some_and(|finished_at| confirmed_frame >= finished_at)
}
}
pub struct MatchRuntimePlugin;
impl Plugin for MatchRuntimePlugin {
fn build(&self, app: &mut App) {
app.rollback_resource_with_clone::<MatchState>()
.checksum_resource_with_hash::<MatchState>()
.init_resource::<PauseState>()
.configure_sets(PreUpdate, RunGgrsSystems.run_if(ggrs_should_run))
.add_systems(OnEnter(AppState::InMatch), reset_pause)
.add_systems(OnExit(AppState::InMatch), reset_pause)
.add_systems(
GgrsSchedule,
update_match_rules.in_set(crate::schedule::GameplaySet::MatchRules),
)
.add_systems(
PreUpdate,
toggle_pause
.after(InputSystems)
.before(RunGgrsSystems)
.run_if(in_state(AppState::InMatch)),
)
.add_systems(
Update,
transition_after_confirmed_finish.run_if(in_state(AppState::InMatch)),
);
}
}
pub fn ggrs_should_run(pause: Option<Res<PauseState>>) -> bool {
!pause.is_some_and(|pause| pause.paused)
}
pub(crate) fn match_is_ongoing(match_state: Option<Res<MatchState>>) -> bool {
match_state.is_none_or(|match_state| match_state.finished_at_frame.is_none())
}
fn toggle_pause(
key: Res<ButtonInput<KeyCode>>,
gamepads: Query<&Gamepad>,
session: Res<SessionConfig>,
mut pause: ResMut<PauseState>,
mut virtual_time: ResMut<Time<Virtual>>,
) {
if session.is_multiplayer() {
return;
}
let pressed = key.just_pressed(KeyCode::Escape)
|| gamepads
.iter()
.any(|gamepad| gamepad.just_pressed(GamepadButton::Start));
if !pressed {
return;
}
pause.paused = !pause.paused;
virtual_time.toggle();
}
fn reset_pause(mut pause: ResMut<PauseState>, mut virtual_time: ResMut<Time<Virtual>>) {
pause.paused = false;
virtual_time.unpause();
}
fn update_match_rules(
mut match_state: ResMut<MatchState>,
match_config: Res<MatchConfig>,
frame: Res<RollbackFrameCount>,
) {
match_state.advance_frame(&match_config, frame.0);
}
fn transition_after_confirmed_finish(
match_state: Res<MatchState>,
confirmed_frame: Res<ConfirmedFrameCount>,
mut next_state: ResMut<NextState<AppState>>,
) {
if match_state.finish_is_confirmed(confirmed_frame.0) {
next_state.set(AppState::MatchResults);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{fighter::FighterId, match_loading::MatchPlayer, stage::manifest::StageId};
fn config(rules: MatchRules) -> MatchConfig {
MatchConfig {
stage: StageId::TestStage,
players: vec![
MatchPlayer {
handle: 1,
controller_port: 3,
fighter: FighterId::TestFighter,
},
MatchPlayer {
handle: 0,
controller_port: 0,
fighter: FighterId::TestFighter,
},
],
rules,
}
}
#[test]
fn stock_state_starts_with_configured_stocks() {
let state = MatchState::from_config(&config(MatchRules::Stock {
starting_stocks: 4,
time_limit_frames: Some(60),
}));
assert_eq!(state.elapsed_frames, 0);
assert_eq!(state.finished_at_frame, None);
assert_eq!(state.players.len(), 2);
assert!(state.players.iter().all(|player| {
player.stocks_remaining == Some(4) && player.score == 0 && player.deaths == 0
}));
}
#[test]
fn timed_state_has_no_stock_counter() {
let state = MatchState::from_config(&config(MatchRules::Timed {
time_limit_frames: 60,
}));
assert!(
state
.players
.iter()
.all(|player| player.stocks_remaining.is_none())
);
}
#[test]
fn timed_match_finishes_on_its_rollback_frame() {
let config = config(MatchRules::Timed {
time_limit_frames: 2,
});
let mut state = MatchState::from_config(&config);
state.advance_frame(&config, 10);
assert_eq!(state.elapsed_frames, 1);
assert_eq!(state.finished_at_frame, None);
state.advance_frame(&config, 11);
assert_eq!(state.elapsed_frames, 2);
assert_eq!(state.finished_at_frame, Some(11));
assert!(!state.finish_is_confirmed(10));
assert!(state.finish_is_confirmed(11));
state.advance_frame(&config, 12);
assert_eq!(state.finished_at_frame, Some(11));
assert_eq!(state.elapsed_frames, 2);
}
#[test]
fn corrected_rollback_state_does_not_keep_speculative_finish() {
let config = config(MatchRules::Stock {
starting_stocks: 1,
time_limit_frames: None,
});
let state_before_prediction = MatchState::from_config(&config);
let mut predicted_state = state_before_prediction.clone();
predicted_state.players[1].stocks_remaining = Some(0);
predicted_state.advance_frame(&config, 20);
assert_eq!(predicted_state.finished_at_frame, Some(20));
let mut corrected_state = state_before_prediction;
corrected_state.advance_frame(&config, 20);
assert_eq!(corrected_state.finished_at_frame, None);
assert!(!corrected_state.finish_is_confirmed(20));
}
#[test]
fn single_player_stock_match_does_not_finish_by_elimination() {
let mut config = config(MatchRules::Stock {
starting_stocks: 1,
time_limit_frames: None,
});
config.players.truncate(1);
let mut state = MatchState::from_config(&config);
state.advance_frame(&config, 1);
assert_eq!(state.finished_at_frame, None);
state.players[0].stocks_remaining = Some(0);
state.advance_frame(&config, 2);
assert_eq!(state.finished_at_frame, None);
}
#[test]
fn single_player_stock_match_can_finish_by_time_limit() {
let mut config = config(MatchRules::Stock {
starting_stocks: 1,
time_limit_frames: Some(1),
});
config.players.truncate(1);
let mut state = MatchState::from_config(&config);
state.advance_frame(&config, 7);
assert_eq!(state.finished_at_frame, Some(7));
}
}
-77
View File
@@ -1,77 +0,0 @@
use bevy::{gltf::GltfMaterialName, pbr::{ExtendedMaterial, MaterialExtension}, prelude::*, render::render_resource::AsBindGroup, shader::ShaderRef, world_serialization::WorldInstanceReady};
pub type FighterMaterial = ExtendedMaterial<StandardMaterial, FighterMaterialExtension>;
#[derive(Component)]
pub struct FighterMesh {
pub fighter: Entity
}
#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)]
pub struct FighterMaterialExtension {
#[uniform(100)]
status_color: LinearRgba,
#[uniform(100)]
status_color_intensity: f32,
}
const SHADER_ASSET_PATH: &str = "embedded://shinespark/shaders/fighter.wgsl";
impl FighterMaterialExtension {
pub fn new(status_color: Color) -> Self {
Self {
status_color: status_color.into(),
status_color_intensity: 0.0,
..default()
}
}
pub fn update_status_color(&mut self, status_color: LinearRgba, intensity: f32) {
self.status_color = status_color;
self.status_color_intensity = intensity;
}
}
impl MaterialExtension for FighterMaterialExtension {
fn fragment_shader() -> ShaderRef {
SHADER_ASSET_PATH.into()
}
fn deferred_fragment_shader() -> ShaderRef {
SHADER_ASSET_PATH.into()
}
}
pub fn bind_fighter_material(
scene_ready: On<WorldInstanceReady>,
mut commands: Commands,
children: Query<&Children>,
//color_override: Query<&ColorOverride>,
mesh_materials: Query<(Entity, &MeshMaterial3d<StandardMaterial>, &GltfMaterialName)>,
mut asset_materials: ResMut<Assets<FighterMaterial>>,
original_materials: Res<Assets<StandardMaterial>>,
) {
for descendant in children.iter_descendants(scene_ready.entity) {
let Ok((entity, id, material_name)) = mesh_materials.get(descendant) else {
continue;
};
dbg!(material_name);
if !material_name.starts_with("Fighter_") {
continue;
}
let Some(original_material) = original_materials.get(id.id()) else {
continue;
};
commands.entity(entity).insert((MeshMaterial3d(asset_materials.add(ExtendedMaterial {
base: StandardMaterial {
// can be used in forward or deferred mode
..original_material.clone()
},
extension: FighterMaterialExtension::new(bevy::color::palettes::css::BLUE.into())
})), FighterMesh { fighter: scene_ready.entity }));
}
}
-24
View File
@@ -1,24 +0,0 @@
use bevy::{
asset::embedded_asset, prelude::*,
};
pub mod fighter_material;
pub struct ShinesparkMaterialPlugin;
pub fn startup_test(asset_server: Res<AssetServer>) {
let test: Handle<Shader> = asset_server.load("embedded://shinespark/shaders/fighter.wgsl");
dbg!(test);
}
impl Plugin for ShinesparkMaterialPlugin {
fn build(&self, app: &mut App) {
let omit_prefix = "";
embedded_asset!(app, omit_prefix, "shaders/fighter.wgsl");
app.add_systems(Startup, startup_test)
.add_observer(fighter_material::bind_fighter_material)
.add_plugins(MaterialPlugin::<fighter_material::FighterMaterial>::default());
}
}
-67
View File
@@ -1,67 +0,0 @@
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
pbr_functions::alpha_discard,
}
#ifdef PREPASS_PIPELINE
#import bevy_pbr::{
prepass_io::{VertexOutput, FragmentOutput},
pbr_deferred_functions::deferred_output,
}
#else
#import bevy_pbr::{
forward_io::{VertexOutput, FragmentOutput},
pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing},
}
#endif
struct FighterExtendedMaterial {
status_color: vec4<f32>,
status_color_intensity: f32,
#ifdef SIXTEEN_BYTE_ALIGNMENT
// Web examples WebGL2 support: structs must be 16 byte aligned.
_webgl2_padding_8b: u32,
_webgl2_padding_12b: u32,
_webgl2_padding_16b: u32,
#endif
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100)
var<uniform> fighter_material: FighterExtendedMaterial;
@fragment
fn fragment(
in: VertexOutput,
@builtin(front_facing) is_front: bool,
) -> FragmentOutput {
// generate a PbrInput struct from the StandardMaterial bindings
var pbr_input = pbr_input_from_standard_material(in, is_front);
// we can optionally modify the input before lighting and alpha_discard is applied
//pbr_input.material.base_color.b = pbr_input.material.base_color.r;
// alpha discard
pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color);
#ifdef PREPASS_PIPELINE
// in deferred mode we can't modify anything after that, as lighting is run in a separate fullscreen shader.
let out = deferred_output(in, pbr_input);
#else
var out: FragmentOutput;
// apply lighting
out.color = apply_pbr_lighting(pbr_input);
// we can optionally modify the lit color before post-processing is applied
var quantize_steps: i32 = 2;
out.color = vec4<f32>(vec4<u32>(out.color * f32(quantize_steps))) / f32(quantize_steps);
// apply in-shader post processing (fog, alpha-premultiply, and also tonemapping, debanding if the camera is non-hdr)
// note this does not include fullscreen postprocessing effects like bloom.
out.color = main_pass_post_lighting_processing(pbr_input, out.color);
out.color = mix(out.color, fighter_material.status_color, fighter_material.status_color_intensity * 0.5);
#endif
return out;
}
+1 -10
View File
@@ -2,7 +2,7 @@ use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use fixed::types::I32F32; use fixed::types::I32F32;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::ops::{Add, AddAssign, Div, Mul, MulAssign, Sub, SubAssign}; use std::ops::{Add, AddAssign, Div, Mul, MulAssign, Sub};
use super::int::{FGWide, FGi32}; use super::int::{FGWide, FGi32};
@@ -227,15 +227,6 @@ impl AddAssign for FGVec2 {
} }
} }
impl SubAssign for FGVec2 {
fn sub_assign(&mut self, rhs: Self) {
*self = Self {
x: self.x - rhs.x,
y: self.y - rhs.y,
}
}
}
impl MulAssign for FGVec2 { impl MulAssign for FGVec2 {
fn mul_assign(&mut self, rhs: Self) { fn mul_assign(&mut self, rhs: Self) {
*self = Self { *self = Self {
+14 -3
View File
@@ -1,8 +1,19 @@
use bevy::{picking::pointer::PointerId, prelude::*}; use bevy::{picking::pointer::PointerId, prelude::*};
use crate::{ use crate::{
AppState, fighter::manifest::{FighterManifest, FighterManifestRegistry}, game_settings::CommonAssets, input::{LocalInputAssignments, LocalInputSource}, menus::{ AppState,
MenuCameraMarker, MenuMarker, button::FGUiButton, character_select::{character_box::CharacterBox, token::CharacterSelectToken}, cursor::{FGMenuCursor, MenuCursorInputSource}, errors::MenuErrors::FighterManifestNotFound, match_config::{MenuMatchConfig, MenuMatchPlayerSlot}, scaling, spawn_menu, style::{FGUiButtonType, FGUiStyle}, fighter::manifest::{FighterManifest, FighterManifestRegistry},
game_settings::CommonAssets,
input::{LocalInputAssignments, LocalInputSource},
menus::{
MenuMarker,
button::FGUiButton,
character_select::{character_box::CharacterBox, token::CharacterSelectToken},
cursor::{FGMenuCursor, MenuCursorInputSource},
errors::MenuErrors::FighterManifestNotFound,
match_config::{MenuMatchConfig, MenuMatchPlayerSlot},
scaling, spawn_menu,
style::{FGUiButtonType, FGUiStyle},
}, },
}; };
@@ -230,7 +241,7 @@ pub fn copy_cursor_transform_to_token(
character_boxes: Query<(&CharacterBox, &ComputedNode, &UiGlobalTransform)>, character_boxes: Query<(&CharacterBox, &ComputedNode, &UiGlobalTransform)>,
match_config: Res<MenuMatchConfig>, match_config: Res<MenuMatchConfig>,
cursors: Query<&UiGlobalTransform, With<FGMenuCursor>>, cursors: Query<&UiGlobalTransform, With<FGMenuCursor>>,
cam: Single<&Camera, With<MenuCameraMarker>>, cam: Single<&Camera, With<IsDefaultUiCamera>>,
ui_scale: Res<UiScale>, ui_scale: Res<UiScale>,
) -> Result { ) -> Result {
let camera = cam.into_inner(); let camera = cam.into_inner();
+6 -3
View File
@@ -9,8 +9,11 @@ use bevy::{
}; };
use crate::{ use crate::{
input::{LocalInputAssignments, LocalInputSource}, menus::{ input::{LocalInputAssignments, LocalInputSource},
MenuCameraMarker, input::{MenuInput, MenuInputState}, scaling, style::FGUiStyle, menus::{
input::{MenuInput, MenuInputState},
scaling,
style::FGUiStyle,
}, },
}; };
@@ -178,7 +181,7 @@ pub fn cursor_input(
} }
pub fn update_cursor_transform( pub fn update_cursor_transform(
camera: Single<&Camera, With<MenuCameraMarker>>, camera: Single<&Camera, With<IsDefaultUiCamera>>,
query: Query<(&mut Node, &PointerLocation), With<FGMenuCursor>>, query: Query<(&mut Node, &PointerLocation), With<FGMenuCursor>>,
ui_scale: Res<UiScale>, ui_scale: Res<UiScale>,
) { ) {
+1 -1
View File
@@ -226,7 +226,7 @@ fn gamepad_input(
input.accept.accumulate(&status); input.accept.accumulate(&status);
} }
MenuInputMapAction::Start => { MenuInputMapAction::Start => {
input.start.accumulate(&status); input.accept.accumulate(&status);
} }
} }
} }
+5 -51
View File
@@ -3,7 +3,7 @@ use thiserror::Error;
use crate::{ use crate::{
fighter::FighterId, fighter::FighterId,
match_loading::{MatchConfig, MatchPlayer, MatchRules}, match_loading::{MatchPlayer, PendingMatch},
stage::manifest::StageId, stage::manifest::StageId,
}; };
@@ -60,15 +60,12 @@ impl MenuMatchConfig {
!has_unselected_fighter !has_unselected_fighter
} }
pub fn to_match_config(&self) -> Result<MatchConfig, MenuMatchConfigErrors> { pub fn to_pending_match(&self) -> Result<PendingMatch, MenuMatchConfigErrors> {
let mut match_players = vec![]; let mut match_players = vec![];
let stage = self.stage.ok_or(MenuMatchConfigErrors::StageNotSelected)?; let stage = self.stage.ok_or(MenuMatchConfigErrors::StageNotSelected)?;
let mut players = self.players.iter().collect::<Vec<_>>(); for player in self.players.iter() {
players.sort_unstable_by_key(|player| player.slot);
for (handle, player) in players.into_iter().enumerate() {
let fighter_id = let fighter_id =
player player
.fighter .fighter
@@ -77,56 +74,13 @@ impl MenuMatchConfig {
))?; ))?;
match_players.push(MatchPlayer { match_players.push(MatchPlayer {
fighter: fighter_id, fighter: fighter_id,
handle, handle: player.slot,
controller_port: player.slot,
}); });
} }
Ok(MatchConfig { Ok(PendingMatch {
players: match_players, players: match_players,
stage, stage,
rules: MatchRules::default(),
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn menu_config_uses_default_match_rules() {
let menu = MenuMatchConfig {
stage: Some(StageId::TestStage),
players: vec![MenuMatchPlayerSlot {
slot: 0,
fighter: Some(FighterId::TestFighter),
}],
};
assert_eq!(menu.to_match_config().unwrap().rules, MatchRules::default());
}
#[test]
fn sparse_controller_ports_get_dense_ggrs_handles() {
let menu = MenuMatchConfig {
stage: Some(StageId::TestStage),
players: vec![
MenuMatchPlayerSlot {
slot: 3,
fighter: Some(FighterId::TestFighter),
},
MenuMatchPlayerSlot {
slot: 0,
fighter: Some(FighterId::TestFighter),
},
],
};
let config = menu.to_match_config().unwrap();
assert_eq!(config.players[0].handle, 0);
assert_eq!(config.players[0].controller_port, 0);
assert_eq!(config.players[1].handle, 1);
assert_eq!(config.players[1].controller_port, 3);
}
}
-44
View File
@@ -1,44 +0,0 @@
use bevy::{prelude::*, text::FontSourceTemplate};
use crate::{AppState, menus::{MenuMarker, spawn_menu}};
pub fn setup_post_match_menu(
existing_menu: Option<Single<Entity, With<MenuMarker>>>,
commands: Commands,
) {
let menu = bsn! {
Node {
flex_direction: FlexDirection::Column,
width: percent(100),
height: percent(100),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center
}
Children [
(
Text("[Insert beautiful post-match screen here]")
TextFont {
font: FontSourceTemplate::Handle("fonts/roboto.ttf"),
font_size: px(super::style::H1_SIZE),
}
TextColor(Color::srgb(0.9, 0.9, 0.9))
),
Text("Press any button/key to continue...")
TextFont {
font: FontSourceTemplate::Handle("fonts/roboto.ttf"),
font_size: px(super::style::H3_SIZE),
}
TextColor(Color::srgb(0.9, 0.9, 0.9))
]
};
spawn_menu(existing_menu, commands, menu);
}
pub fn check_return_to_character_select(gamepads: Query<&Gamepad>, keyboard: Res<ButtonInput<KeyCode>>, mut next_state: ResMut<NextState<AppState>>) {
let any_pressed = gamepads.iter().any(|gp| gp.get_just_pressed().count() > 0);
let any_pressed = any_pressed || keyboard.get_just_pressed().count() > 0;
if any_pressed {
next_state.set(AppState::MainMenu);
}
}
+126 -130
View File
@@ -18,7 +18,6 @@ pub mod errors;
pub mod input; pub mod input;
pub mod main_menu; pub mod main_menu;
pub mod match_config; pub mod match_config;
pub mod match_results;
pub mod navigation; pub mod navigation;
pub mod scaling; pub mod scaling;
pub mod stage_select; pub mod stage_select;
@@ -26,8 +25,6 @@ pub mod style;
#[derive(Component, Clone, Default)] #[derive(Component, Clone, Default)]
pub struct MenuMarker {} pub struct MenuMarker {}
#[derive(Component, Clone, Default)]
pub struct MenuCameraMarker {}
pub struct MenuPlugin; pub struct MenuPlugin;
@@ -41,7 +38,6 @@ pub fn wrap_menu(menu: impl Scene) -> impl Scene {
bsn! { bsn! {
#MenuRoot #MenuRoot
MenuMarker MenuMarker
Transform
Node { Node {
width: percent(100), width: percent(100),
height: percent(100), height: percent(100),
@@ -50,12 +46,6 @@ pub fn wrap_menu(menu: impl Scene) -> impl Scene {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
} }
Children [ Children [
(
#MenuCamera
Camera
Camera2d
MenuCameraMarker
),
( (
#MenuContainer #MenuContainer
Node { Node {
@@ -78,7 +68,11 @@ pub fn despawn_menu(
mut commands: Commands, mut commands: Commands,
) { ) {
if let Some(entity) = existing_menu { if let Some(entity) = existing_menu {
commands.entity(entity.into_inner()).despawn(); commands
.entity(entity.into_inner())
.remove::<Camera2d>()
.remove::<Camera>()
.despawn();
} }
} }
@@ -88,7 +82,11 @@ pub fn spawn_menu(
menu: impl Scene, menu: impl Scene,
) { ) {
if let Some(entity) = existing_menu { if let Some(entity) = existing_menu {
commands.entity(entity.into_inner()).despawn(); commands
.entity(entity.into_inner())
.remove::<Camera2d>()
.remove::<Camera>()
.despawn();
} }
commands.queue_spawn_scene(wrap_menu(menu)); commands.queue_spawn_scene(wrap_menu(menu));
} }
@@ -113,126 +111,124 @@ where
impl Plugin for MenuPlugin { impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::MainMenu), build_menu::<MainMenu>) app.add_systems(
.add_message::<character_select::PlayerSlotAssigned>() OnEnter(AppState::MainMenu),
.init_resource::<navigation::UINavigationActionState>() (build_menu::<MainMenu>, |mut commands: Commands| {
.init_resource::<navigation::UINavigationRepeat>() commands.spawn((Camera2d, IsDefaultUiCamera));
.init_resource::<input::MenuInputState>() commands.spawn((
.insert_resource(InputFocusVisible(true)) Camera2d,
.insert_resource(GlobalUiDebugOptions { ..default() }) Camera {
// Configure auto-navigation behavior order: 1,
.insert_resource(AutoNavigationConfig { ..default()
// Require at least 10% overlap in perpendicular axis for cardinal directions },
min_alignment_factor: 0.1, ));
// Don't connect nodes more than 500 pixels apart between their closest edges }),
max_search_distance: Some(500.0), )
// Prefer nodes that are well-aligned .add_message::<character_select::PlayerSlotAssigned>()
prefer_aligned: true, .init_resource::<navigation::UINavigationActionState>()
}) .init_resource::<navigation::UINavigationRepeat>()
.add_plugins(DirectionalNavigationPlugin) .init_resource::<input::MenuInputState>()
.add_systems( .insert_resource(InputFocusVisible(true))
PreUpdate, .insert_resource(GlobalUiDebugOptions {
( enabled: true,
navigation::give_default_focus, ..default()
scaling::ui_scaling_system, })
button::button_setup, // Configure auto-navigation behavior
button::button_style_system.after(bevy::picking::PickingSystems::Hover), .insert_resource(AutoNavigationConfig {
button::button_focus_style_system, // Require at least 10% overlap in perpendicular axis for cardinal directions
) min_alignment_factor: 0.1,
.run_if(resource_exists::<CommonAssets>), // Don't connect nodes more than 500 pixels apart between their closest edges
max_search_distance: Some(500.0),
// Prefer nodes that are well-aligned
prefer_aligned: true,
})
.add_plugins(DirectionalNavigationPlugin)
.add_systems(
PreUpdate,
(
navigation::give_default_focus,
scaling::ui_scaling_system,
button::button_setup,
button::button_style_system.after(bevy::picking::PickingSystems::Hover),
button::button_focus_style_system,
) )
.add_systems(Startup, scaling::ui_update_scale_on_startup) .run_if(resource_exists::<CommonAssets>),
// CURSOR )
.add_systems( .add_systems(Startup, scaling::ui_update_scale_on_startup)
PreUpdate, // CURSOR
(cursor::cursor_input).before(bevy::picking::PickingSystems::ProcessInput), .add_systems(
PreUpdate,
(cursor::cursor_input).before(bevy::picking::PickingSystems::ProcessInput),
)
.add_systems(
Update,
(cursor::copy_mouse_input, cursor::update_cursor_transform),
)
.add_systems(
PreUpdate,
input::sample_menu_inputs
.before(cursor::cursor_input)
.run_if(resource_exists::<crate::input::MenuInputMap>),
)
//---------
// CHARACTER SELECT START
//---------
.add_systems(
OnEnter(AppState::CharacterSelect),
character_select::setup_character_select,
)
.add_systems(
PostUpdate,
character_select::copy_cursor_transform_to_token
.run_if(in_state(AppState::CharacterSelect))
.run_if(any_with_component::<CharacterSelectScreen>),
)
.add_systems(
PreUpdate,
(
character_select::css_auto_assign_slots,
character_select::handle_fighter_slot_addition,
) )
.add_systems( .chain()
Update, .run_if(in_state(AppState::CharacterSelect))
(cursor::copy_mouse_input, cursor::update_cursor_transform), .run_if(any_with_component::<CharacterSelectScreen>),
)
.add_systems(
OnExit(AppState::CharacterSelect),
(cursor::despawn_cursors, character_select::despawn_tokens),
)
.add_observer(character_select::select_fighter.run_if(in_state(AppState::CharacterSelect)))
.add_observer(
character_select::update_start_banner_visibility
.run_if(in_state(AppState::CharacterSelect)),
)
//---------
// CHARACTER SELECT END
//---------
//---------
// STAGE SELECT START
//---------
.add_systems(
OnEnter(AppState::StageSelect),
stage_select::setup_stage_select,
)
.add_observer(stage_select::stage_selected.run_if(in_state(AppState::StageSelect)))
.add_systems(
OnExit(AppState::StageSelect),
(cursor::despawn_cursors, despawn_menu),
)
//---------
// STAGE SELECT END
//---------
.add_systems(
Update,
(
navigation::process_inputs,
navigation::navigate,
navigation::interact_with_focused_button,
) )
.add_systems( .chain(),
PreUpdate, )
input::sample_menu_inputs .init_resource::<InputFocus>();
.before(cursor::cursor_input)
.run_if(resource_exists::<crate::input::MenuInputMap>),
)
//---------
// CHARACTER SELECT START
//---------
.add_systems(
OnEnter(AppState::CharacterSelect),
character_select::setup_character_select,
)
.add_systems(
PostUpdate,
character_select::copy_cursor_transform_to_token
.run_if(in_state(AppState::CharacterSelect))
.run_if(any_with_component::<CharacterSelectScreen>),
)
.add_systems(
PreUpdate,
(
character_select::css_auto_assign_slots,
character_select::handle_fighter_slot_addition,
)
.chain()
.run_if(in_state(AppState::CharacterSelect))
.run_if(any_with_component::<CharacterSelectScreen>),
)
.add_systems(
OnExit(AppState::CharacterSelect),
(cursor::despawn_cursors, character_select::despawn_tokens),
)
.add_observer(
character_select::select_fighter.run_if(in_state(AppState::CharacterSelect)),
)
.add_observer(
character_select::update_start_banner_visibility
.run_if(in_state(AppState::CharacterSelect)),
)
//---------
// CHARACTER SELECT END
//---------
//---------
// STAGE SELECT START
//---------
.add_systems(
OnEnter(AppState::StageSelect),
stage_select::setup_stage_select,
)
.add_observer(stage_select::stage_selected.run_if(in_state(AppState::StageSelect)))
.add_systems(
OnExit(AppState::StageSelect),
(cursor::despawn_cursors, despawn_menu),
)
//---------
// STAGE SELECT END
//---------
//---------
// MATCH RESULT START
//---------
.add_systems(
OnEnter(AppState::MatchResults),
match_results::setup_post_match_menu,
)
.add_systems(
PostUpdate,
match_results::check_return_to_character_select
.run_if(in_state(AppState::MatchResults)),
)
//---------
// MATCH RESULT END
//---------
.add_systems(
Update,
(
navigation::process_inputs,
navigation::navigate,
navigation::interact_with_focused_button,
)
.chain(),
)
.init_resource::<InputFocus>();
} }
} }
+1 -1
View File
@@ -102,7 +102,7 @@ pub fn process_inputs(
.insert(DirectionalNavigationAction::Right); .insert(DirectionalNavigationAction::Right);
} }
} }
if input.accept.is_just_pressed() { if input.accept.is_pressed() {
action_state action_state
.pressed_actions .pressed_actions
.insert(DirectionalNavigationAction::Select); .insert(DirectionalNavigationAction::Select);
+1 -1
View File
@@ -77,7 +77,7 @@ pub fn stage_selected(
initiate_match( initiate_match(
&mut commands, &mut commands,
&mut next_state, &mut next_state,
match_config.to_match_config()?, match_config.to_pending_match()?,
); );
} }
Ok(()) Ok(())
-17
View File
@@ -42,15 +42,6 @@ pub enum SessionConfigError {
} }
impl SessionConfig { impl SessionConfig {
/// Returns whether this match has more than one remote participant.
pub fn is_multiplayer(&self) -> bool {
self.players.len() > 1
|| self
.players
.iter()
.any(|player| matches!(player, PlayerConnection::Remote(_)))
}
pub fn from_args(args: &Args, player_count: usize) -> Result<Self, SessionConfigError> { pub fn from_args(args: &Args, player_count: usize) -> Result<Self, SessionConfigError> {
if player_count == 0 { if player_count == 0 {
return Err(SessionConfigError::NoPlayers); return Err(SessionConfigError::NoPlayers);
@@ -205,13 +196,6 @@ mod tests {
config.players, config.players,
[PlayerConnection::Local, PlayerConnection::Local] [PlayerConnection::Local, PlayerConnection::Local]
); );
assert!(config.is_multiplayer());
}
#[test]
fn single_local_player_is_not_multiplayer() {
let config = SessionConfig::from_args(&args(&["game", "--players", "1"]), 1).unwrap();
assert!(!config.is_multiplayer());
} }
#[test] #[test]
@@ -223,7 +207,6 @@ mod tests {
.unwrap(); .unwrap();
assert!(matches!(config.players[0], PlayerConnection::Local)); assert!(matches!(config.players[0], PlayerConnection::Local));
assert!(matches!(config.players[1], PlayerConnection::Remote(_))); assert!(matches!(config.players[1], PlayerConnection::Remote(_)));
assert!(config.is_multiplayer());
} }
#[test] #[test]
-3
View File
@@ -10,8 +10,5 @@ use bevy_ggrs::prelude::*;
#[derive(Default, Component)] #[derive(Default, Component)]
#[require(Rollback, Transform)] #[require(Rollback, Transform)]
pub struct Player { pub struct Player {
/// Dense GGRS session index.
pub handle: usize, pub handle: usize,
/// Port used for port-dependent gameplay such as respawn placement.
pub controller_port: usize,
} }
+47 -105
View File
@@ -12,12 +12,16 @@ use clap::ValueEnum;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
AppState, args::Args, input::FighterInputFrame, match_loading::MatchConfig, netcode::GGRSCfg, AppState,
args::Args,
input::FighterInputFrame,
match_loading::{MatchPlayer, PendingMatch},
netcode::GGRSCfg,
schedule::GameplaySet, schedule::GameplaySet,
}; };
const MAGIC: &[u8; 8] = b"SPRPLY01"; const MAGIC: &[u8; 8] = b"SPRPLY01";
pub const REPLAY_VERSION: u32 = 3; pub const REPLAY_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum ReplayFormat { pub enum ReplayFormat {
@@ -30,11 +34,17 @@ pub enum ReplayFormat {
pub struct Replay { pub struct Replay {
pub replay_version: u32, pub replay_version: u32,
pub game_version: String, pub game_version: String,
pub match_config: MatchConfig, pub match_config: ReplayMatch,
pub frames: Vec<ReplayFrame>, pub frames: Vec<ReplayFrame>,
pub checksums: Vec<ReplayChecksum>, pub checksums: Vec<ReplayChecksum>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReplayMatch {
pub stage: crate::stage::manifest::StageId,
pub players: Vec<MatchPlayer>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReplayFrame { pub struct ReplayFrame {
pub frame: i32, pub frame: i32,
@@ -140,7 +150,7 @@ fn validate(replay: &Replay) -> Result<(), String> {
)); ));
} }
if replay.players_are_invalid() { if replay.players_are_invalid() {
return Err("replay player handles must be unique".into()); return Err("replay player handles must cover 0..player_count".into());
} }
if replay.frames.is_empty() { if replay.frames.is_empty() {
return Err("replay must contain at least one frame".into()); return Err("replay must contain at least one frame".into());
@@ -152,20 +162,6 @@ fn validate(replay: &Replay) -> Result<(), String> {
{ {
return Err("replay frames must be strictly ordered".into()); return Err("replay frames must be strictly ordered".into());
} }
if replay
.frames
.windows(2)
.any(|frames| frames[0].frame.checked_add(1) != Some(frames[1].frame))
{
return Err("replay frames must be contiguous".into());
}
if replay
.checksums
.windows(2)
.any(|checksums| checksums[0].frame >= checksums[1].frame)
{
return Err("replay checksums must be strictly ordered".into());
}
if replay if replay
.frames .frames
.iter() .iter()
@@ -185,22 +181,7 @@ impl Replay {
.map(|player| player.handle) .map(|player| player.handle)
.collect(); .collect();
handles.sort_unstable(); handles.sort_unstable();
handles.is_empty() || !handles.into_iter().eq(0..self.match_config.players.len()) handles != (0..handles.len()).collect::<Vec<_>>()
}
fn frame(&self, frame: i32) -> Option<&ReplayFrame> {
let first_frame = self.frames.first()?.frame;
let index = frame.checked_sub(first_frame)? as usize;
self.frames
.get(index)
.filter(|recorded| recorded.frame == frame)
}
fn checksum(&self, frame: i32) -> Option<u128> {
self.checksums
.binary_search_by_key(&frame, |checksum| checksum.frame)
.ok()
.map(|index| self.checksums[index].checksum)
} }
} }
@@ -239,7 +220,11 @@ fn replay_inputs(
.insert(frame.0, inputs.iter().map(|(input, _)| *input).collect()); .insert(frame.0, inputs.iter().map(|(input, _)| *input).collect());
} }
ReplayMode::Playback { replay, mismatch } if !*mismatch => { ReplayMode::Playback { replay, mismatch } if !*mismatch => {
let Some(recorded) = replay.frame(frame.0) else { let Some(recorded) = replay
.frames
.iter()
.find(|recorded| recorded.frame == frame.0)
else {
error!("Replay has no input for frame {}", frame.0); error!("Replay has no input for frame {}", frame.0);
*mismatch = true; *mismatch = true;
return; return;
@@ -260,15 +245,21 @@ fn record_checksum(
let Some(mut runtime) = runtime else { return }; let Some(mut runtime) = runtime else { return };
if matches!(&runtime.mode, ReplayMode::Record { .. }) { if matches!(&runtime.mode, ReplayMode::Record { .. }) {
runtime.checksums.insert(frame.0, checksum.0); runtime.checksums.insert(frame.0, checksum.0);
} else if let ReplayMode::Playback { replay, mismatch } = &mut runtime.mode } else if let ReplayMode::Playback { replay, mismatch } = &mut runtime.mode {
&& let Some(expected) = replay.checksum(frame.0) if let Some(expected) = replay
&& expected != checksum.0 .checksums
{ .iter()
error!( .find(|value| value.frame == frame.0)
"Replay desync at frame {}: expected {:X}, got {:X}", .map(|value| value.checksum)
frame.0, expected, checksum.0 {
); if expected != checksum.0 {
*mismatch = true; error!(
"Replay desync at frame {}: expected {:X}, got {:X}",
frame.0, expected, checksum.0
);
*mismatch = true;
}
}
} }
} }
@@ -276,27 +267,27 @@ fn stop_recording(
mut commands: Commands, mut commands: Commands,
stop_request: Option<Res<StopReplayRecording>>, stop_request: Option<Res<StopReplayRecording>>,
runtime: Option<ResMut<ReplayRuntime>>, runtime: Option<ResMut<ReplayRuntime>>,
config: Option<Res<MatchConfig>>, request: Option<Res<PendingMatch>>,
) { ) {
if stop_request.is_none() { if stop_request.is_none() {
return; return;
} }
commands.remove_resource::<StopReplayRecording>(); commands.remove_resource::<StopReplayRecording>();
finish_recording_inner(&mut commands, runtime, config); finish_recording_inner(&mut commands, runtime, request);
} }
fn finish_recording( fn finish_recording(
mut commands: Commands, mut commands: Commands,
runtime: Option<ResMut<ReplayRuntime>>, runtime: Option<ResMut<ReplayRuntime>>,
config: Option<Res<MatchConfig>>, request: Option<Res<PendingMatch>>,
) { ) {
finish_recording_inner(&mut commands, runtime, config); finish_recording_inner(&mut commands, runtime, request);
} }
fn finish_recording_inner( fn finish_recording_inner(
commands: &mut Commands, commands: &mut Commands,
runtime: Option<ResMut<ReplayRuntime>>, runtime: Option<ResMut<ReplayRuntime>>,
config: Option<Res<MatchConfig>>, request: Option<Res<PendingMatch>>,
) { ) {
let Some(mut runtime) = runtime else { return }; let Some(mut runtime) = runtime else { return };
let ReplayMode::Record { path, format } = &runtime.mode else { let ReplayMode::Record { path, format } = &runtime.mode else {
@@ -305,13 +296,16 @@ fn finish_recording_inner(
if runtime.finished || runtime.frames.is_empty() { if runtime.finished || runtime.frames.is_empty() {
return; return;
} }
let Some(config) = config else { return }; let Some(request) = request else { return };
let path = path.clone(); let path = path.clone();
let format = *format; let format = *format;
let replay = Replay { let replay = Replay {
replay_version: REPLAY_VERSION, replay_version: REPLAY_VERSION,
game_version: env!("CARGO_PKG_VERSION").into(), game_version: env!("CARGO_PKG_VERSION").into(),
match_config: config.as_ref().clone(), match_config: ReplayMatch {
stage: request.stage,
players: request.players.clone(),
},
frames: runtime frames: runtime
.frames .frames
.iter() .iter()
@@ -361,7 +355,7 @@ mod tests {
use super::*; use super::*;
use crate::{ use crate::{
fighter::FighterId, fighter::FighterId,
match_loading::{MatchPlayer, MatchRules}, match_loading::MatchPlayer,
math::{int::FGi32, vec::FGVec2}, math::{int::FGi32, vec::FGVec2},
stage::manifest::StageId, stage::manifest::StageId,
}; };
@@ -370,16 +364,12 @@ mod tests {
Replay { Replay {
replay_version: REPLAY_VERSION, replay_version: REPLAY_VERSION,
game_version: "test".into(), game_version: "test".into(),
match_config: MatchConfig { match_config: ReplayMatch {
stage: StageId::TestStage, stage: StageId::TestStage,
players: vec![MatchPlayer { players: vec![MatchPlayer {
handle: 0, handle: 0,
controller_port: 0,
fighter: FighterId::TestFighter, fighter: FighterId::TestFighter,
}], }],
rules: MatchRules::Timed {
time_limit_frames: 21_600,
},
}, },
frames: vec![ReplayFrame { frames: vec![ReplayFrame {
frame: 1, frame: 1,
@@ -416,52 +406,4 @@ mod tests {
replay.frames[0].inputs.clear(); replay.frames[0].inputs.clear();
assert!(validate(&replay).is_err()); assert!(validate(&replay).is_err());
} }
#[test]
fn unsupported_replay_version_is_rejected() {
let mut replay = sample();
replay.replay_version = 1;
assert_eq!(
validate(&replay).unwrap_err(),
"unsupported replay version 1"
);
}
#[test]
fn playback_lookups_use_frame_offsets_and_support_sparse_checksums() {
let mut replay = sample();
replay.frames[0].frame = 10;
replay.frames.push(ReplayFrame {
frame: 11,
inputs: replay.frames[0].inputs.clone(),
});
replay.checksums.push(ReplayChecksum {
frame: 11,
checksum: 84,
});
replay.checksums[0].frame = 10;
validate(&replay).unwrap();
assert_eq!(replay.frame(10).unwrap().frame, 10);
assert_eq!(replay.frame(11).unwrap().frame, 11);
assert!(replay.frame(9).is_none());
assert_eq!(replay.checksum(10), Some(42));
assert_eq!(replay.checksum(11), Some(84));
assert_eq!(replay.checksum(12), None);
}
#[test]
fn noncontiguous_input_frames_are_rejected() {
let mut replay = sample();
replay.frames.push(ReplayFrame {
frame: 3,
inputs: replay.frames[0].inputs.clone(),
});
assert_eq!(
validate(&replay).unwrap_err(),
"replay frames must be contiguous"
);
}
} }
+4 -10
View File
@@ -9,8 +9,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_ggrs::prelude::*; use bevy_ggrs::prelude::*;
use crate::match_runtime::match_is_ongoing;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum GameplaySet { pub enum GameplaySet {
/// Turn this frame's netcode inputs into per-fighter input state. /// Turn this frame's netcode inputs into per-fighter input state.
@@ -21,14 +19,12 @@ pub enum GameplaySet {
StateUpdate, StateUpdate,
/// Integrate velocities into positions. /// Integrate velocities into positions.
Physics, Physics,
/// Resolve the integrated positions against the stage.
Collision,
/// Solve collision boxes /// Solve collision boxes
Animation, Animation,
/// Resolve the integrated positions against the stage.
Collision,
/// Resolve attacks /// Resolve attacks
Attacks, Attacks,
/// Advance match rules and detect the end of the match.
MatchRules,
} }
/// Declares the order of [`GameplaySet`] inside [`GgrsSchedule`]. Added by /// Declares the order of [`GameplaySet`] inside [`GgrsSchedule`]. Added by
@@ -44,13 +40,11 @@ impl Plugin for GameplaySchedulePlugin {
GameplaySet::Interrupt, GameplaySet::Interrupt,
GameplaySet::StateUpdate, GameplaySet::StateUpdate,
GameplaySet::Physics, GameplaySet::Physics,
GameplaySet::Animation,
GameplaySet::Collision, GameplaySet::Collision,
GameplaySet::Animation,
GameplaySet::Attacks, GameplaySet::Attacks,
GameplaySet::MatchRules,
) )
.chain() .chain(),
.run_if(match_is_ongoing),
); );
} }
} }
+5 -5
View File
@@ -117,7 +117,7 @@ pub fn register_common_types(engine: &mut rhai::Engine) {
.register_fn("knockback_normal", KnockbackType::make_normal) .register_fn("knockback_normal", KnockbackType::make_normal)
.register_fn("knockback_fixed", KnockbackType::make_fixed) .register_fn("knockback_fixed", KnockbackType::make_fixed)
.register_type_with_name::<AttackAngle>("Angle") .register_type_with_name::<AttackAngle>("Angle")
.register_fn("angle", |str: &str| AttackAngle::make_normal(FGi32::lit(str))) .register_fn("angle", AttackAngle::make_normal)
.register_fn("angle_sakurai", AttackAngle::make_sakurai) .register_fn("angle_sakurai", AttackAngle::make_sakurai)
.register_type_with_name::<Knockback>("Knockback") .register_type_with_name::<Knockback>("Knockback")
.register_fn("knockback", Knockback::lit) .register_fn("knockback", Knockback::lit)
@@ -261,18 +261,18 @@ pub fn compile_script(text: &str) -> Result<FighterAttackScript, String> {
mod tests { mod tests {
use super::*; use super::*;
const HITBOX: &str = "hitbox(0, \"hand_l\", damage(\"1\"), vec3(\"0\", \"0\", \"0\"), fg32(\"1\"), angle(\"0\"), knockback_normal(), knockback(\"1\"), fg32(\"1\"));"; const HITBOX: &str = "hitbox(0, \"hand_l\", damage(\"1\"), vec3(\"0\", \"0\", \"0\"), fg32(\"1\"), angle(fg32(\"0\")), knockback_normal(), knockback(\"1\"), fg32(\"1\"));";
#[test] #[test]
fn compiled_hitboxes_are_sorted_by_authored_id() { fn compiled_hitboxes_are_sorted_by_authored_id() {
let script = compile_script( let script = compile_script(
r#" r#"
frame(1); frame(1);
hitbox(20, "hand_l", damage("1"), vec3("0", "0", "0"), fg32("1"), angle("0"), knockback_normal(), knockback("1"), fg32("1")); hitbox(20, "hand_l", damage("1"), vec3("0", "0", "0"), fg32("1"), angle(fg32("0")), knockback_normal(), knockback("1"), fg32("1"));
frame(2); frame(2);
hitbox(3, "hand_l", damage("2"), vec3("0", "0", "0"), fg32("1"), angle("0"), knockback_normal(), knockback("1"), fg32("1")); hitbox(3, "hand_l", damage("2"), vec3("0", "0", "0"), fg32("1"), angle(fg32("0")), knockback_normal(), knockback("1"), fg32("1"));
frame(3); frame(3);
hitbox(10, "hand_l", damage("3"), vec3("0", "0", "0"), fg32("1"), angle("0"), knockback_normal(), knockback("1"), fg32("1")); hitbox(10, "hand_l", damage("3"), vec3("0", "0", "0"), fg32("1"), angle(fg32("0")), knockback_normal(), knockback("1"), fg32("1"));
frame(4); frame(4);
"#, "#,
) )
+1 -55
View File
@@ -1,11 +1,6 @@
use bevy::{color::palettes::css::RED, prelude::*}; use bevy::{color::palettes::css::RED, prelude::*};
use crate::{ use crate::stage::line::StageCollision;
match_loading::MatchConfig, math::{int::FGi32, vec3::FGVec3}, stage::{
line::StageCollision,
manifest::{StageManifest, StageManifestRegistry},
},
};
pub fn debug_draw_scene(mut gizmos: Gizmos, stage_collision: Res<StageCollision>) { pub fn debug_draw_scene(mut gizmos: Gizmos, stage_collision: Res<StageCollision>) {
for poly in stage_collision.stage_polys.iter() { for poly in stage_collision.stage_polys.iter() {
@@ -21,52 +16,3 @@ pub fn debug_draw_scene(mut gizmos: Gizmos, stage_collision: Res<StageCollision>
} }
} }
} }
pub fn debug_draw_blast_zones(
mut gizmos: Gizmos,
stage_registry: Res<StageManifestRegistry>,
stage_manifests: Res<Assets<StageManifest>>,
match_config: Res<MatchConfig>,
) {
let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else {
return;
};
let Some(stage_manifest) = stage_manifests.get(&stage_manifest_handle) else {
return;
};
let top_left = stage_manifest.blast_zone_top_left.to_vec2();
let bottom_right = stage_manifest.blast_zone_bottom_right.to_vec2();
let center = (top_left + bottom_right) / 2.0;
let size = (bottom_right - top_left).abs();
gizmos.rect(
Vec3::new(center.x, center.y, 0.0),
size,
bevy::color::palettes::css::BLUE,
);
}
pub fn debug_draw_spawn_positions(
mut gizmos: Gizmos,
stage_registry: Res<StageManifestRegistry>,
stage_manifests: Res<Assets<StageManifest>>,
match_config: Res<MatchConfig>,
) {
let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else {
return;
};
let Some(stage_manifest) = stage_manifests.get(&stage_manifest_handle) else {
return;
};
for spawn_point in stage_manifest.spawn_points.iter() {
gizmos.cross(FGVec3::new(spawn_point.x, spawn_point.y, FGi32::ZERO).to_vec3(), 2.0, bevy::color::palettes::css::BLUE);
}
for respawn_point in stage_manifest.respawn_points.iter() {
gizmos.cross(FGVec3::new(respawn_point.x, respawn_point.y, FGi32::ZERO).to_vec3(), 2.0, bevy::color::palettes::css::HOT_PINK);
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ pub enum StagePolyType {
Platform, Platform,
} }
#[derive(Reflect, Hash, Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Reflect, Hash, Debug, Clone, Copy, Default)]
pub struct StageLineID { pub struct StageLineID {
pub polygon: usize, pub polygon: usize,
pub segment: usize, pub segment: usize,
@@ -34,7 +34,7 @@ pub struct StageLineID {
#[derive(Reflect, Clone, Default)] #[derive(Reflect, Clone, Default)]
pub struct StagePoly { pub struct StagePoly {
pub poly_type: StagePolyType, poly_type: StagePolyType,
pub segments: Vec<StagePolyLineSegment>, pub segments: Vec<StagePolyLineSegment>,
} }
+7 -88
View File
@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use bevy_asset_loader::asset_collection::AssetCollection; use bevy_asset_loader::asset_collection::AssetCollection;
use ron_asset_manager::prelude::RonAsset; use ron_asset_manager::prelude::RonAsset;
@@ -7,10 +7,14 @@ use strum::IntoEnumIterator;
use strum_macros::EnumIter; use strum_macros::EnumIter;
use crate::{ use crate::{
AppState, game_settings::GameSettings, math::{vec::FGVec2}, stage::{ AppState,
game_settings::GameSettings,
math::vec::FGVec2,
stage::{
StagePoly, StagePoly,
line::{StageCollision, StagePolyLineSegmentType, StagePolyType}, line::{StageCollision, StagePolyLineSegmentType, StagePolyType},
}, texture_reference::TextureReference, },
texture_reference::TextureReference,
}; };
use bevy::prelude::*; use bevy::prelude::*;
@@ -83,17 +87,6 @@ pub struct StageManifest {
pub icon: TextureReference, pub icon: TextureReference,
pub camera: StageCameraProfile, pub camera: StageCameraProfile,
pub polygons: Vec<StagePolyDefinition>, pub polygons: Vec<StagePolyDefinition>,
#[reflect(ignore)]
pub blast_zone_top_left: FGVec2,
#[reflect(ignore)]
pub blast_zone_bottom_right: FGVec2,
#[reflect(ignore)]
pub spawn_points: Vec<FGVec2>,
pub neutral_spawn_point_sets: Vec<Vec<usize>>,
#[reflect(ignore)]
pub respawn_points: Vec<FGVec2>,
} }
#[derive(Resource)] #[derive(Resource)]
@@ -169,13 +162,6 @@ pub fn prepare_stage_manifests(
); );
return; return;
} }
if let Err(error) = manifest.validate_spawn_points() {
fail(
&mut next_state,
&format!("{:?} has invalid spawn points: {error}", manifest.id),
);
return;
}
if registry.insert(manifest.id, handle.clone()).is_some() { if registry.insert(manifest.id, handle.clone()).is_some() {
fail( fail(
&mut next_state, &mut next_state,
@@ -200,73 +186,6 @@ pub fn prepare_stage_manifests(
} }
impl StageManifest { impl StageManifest {
pub fn neutral_spawn_positions(&self, player_count: usize) -> Result<Vec<FGVec2>, String> {
if player_count == 0 {
return Err("a match must contain at least one player".to_owned());
}
let layout = self
.neutral_spawn_point_sets
.get(player_count - 1)
.ok_or_else(|| format!("no spawn layout for {player_count} players"))?;
if layout.len() != player_count {
return Err(format!(
"the {player_count}-player layout contains {} positions",
layout.len()
));
}
layout
.iter()
.map(|&index| {
self.spawn_points.get(index).copied().ok_or_else(|| {
format!("the {player_count}-player layout references spawn point {index}")
})
})
.collect()
}
fn validate_spawn_points(&self) -> Result<(), String> {
if self.spawn_points.is_empty() {
return Err("no spawn points are configured".to_owned());
}
if self.respawn_points.is_empty() {
return Err("no respawn points are configured".to_owned());
}
if self.neutral_spawn_point_sets.is_empty() {
return Err("no neutral spawn layouts are configured".to_owned());
}
for (layout_index, layout) in self.neutral_spawn_point_sets.iter().enumerate() {
let player_count = layout_index + 1;
if layout.len() != player_count {
return Err(format!(
"the {player_count}-player layout contains {} positions",
layout.len()
));
}
let mut unique_indices = HashSet::with_capacity(layout.len());
for &spawn_point_index in layout {
if spawn_point_index >= self.spawn_points.len() {
return Err(format!(
"the {player_count}-player layout references spawn point {spawn_point_index}"
));
}
if !unique_indices.insert(spawn_point_index) {
return Err(format!(
"the {player_count}-player layout uses spawn point {spawn_point_index} more than once"
));
}
}
}
Ok(())
}
pub fn to_collision(&self) -> StageCollision { pub fn to_collision(&self) -> StageCollision {
let stage_polys: Vec<StagePoly> = self let stage_polys: Vec<StagePoly> = self
.polygons .polygons
+4 -12
View File
@@ -10,7 +10,7 @@ pub mod scene;
pub use line::{StagePlaneIntersectResult, StagePoly}; pub use line::{StagePlaneIntersectResult, StagePoly};
use crate::{match_loading::MatchConfig, stage::{line::StageCollision, manifest::StageCameraProfile}}; use crate::stage::{line::StageCollision, manifest::StageCameraProfile};
/// Owns the stage geometry: which scene gets spawned and how it is drawn. /// Owns the stage geometry: which scene gets spawned and how it is drawn.
#[derive(Default)] #[derive(Default)]
@@ -20,17 +20,9 @@ impl Plugin for StagePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems( app.add_systems(
Update, Update,
( debug::debug_draw_scene
(debug::debug_draw_scene .run_if(resource_exists::<line::StageCollision>)
.run_if(resource_exists::<line::StageCollision>) .run_if(crate::debug_tools::stage_collision_enabled),
.run_if(crate::debug_tools::stage_collision_enabled)),
(debug::debug_draw_blast_zones
.run_if(resource_exists::<MatchConfig>)
.run_if(crate::debug_tools::blast_zones_enabled),),
(debug::debug_draw_spawn_positions
.run_if(resource_exists::<MatchConfig>)
.run_if(crate::debug_tools::spawn_positions_enabled),),
),
); );
} }
} }
+8 -17
View File
@@ -1,29 +1,20 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{
fighter::manifest::{FighterManifest, FighterManifestRegistry}, fighter::manifest::{FighterManifest, FighterManifestRegistry}, match_loading::{PendingMatch}, ui::{errors::UIErrors, fighter_info::FighterInfoUI},
match_loading::MatchConfig,
ui::{errors::UIErrors, fighter_info::FighterInfoUI},
}; };
#[derive(Component, FromTemplate)] #[derive(Component, FromTemplate)]
pub struct BattleUI {} pub struct BattleUI {
}
pub fn spawn_battle_ui(mut commands: Commands, request: Res<PendingMatch>, fighter_registry: Res<FighterManifestRegistry>, manifests: Res<Assets<FighterManifest>>) -> Result {
pub fn spawn_battle_ui(
mut commands: Commands,
config: Res<MatchConfig>,
fighter_registry: Res<FighterManifestRegistry>,
manifests: Res<Assets<FighterManifest>>,
) -> Result {
let mut fighter_uis = vec![]; let mut fighter_uis = vec![];
for player in &config.players { for player in &request.players {
let manifest = fighter_registry let manifest = fighter_registry.get(player.fighter).ok_or_else(|| UIErrors::FighterManifestHandleNotFound(player.fighter))?;
.get(player.fighter) let manifest = manifests.get(manifest).ok_or_else(|| UIErrors::FighterManifestNotFound(player.fighter))?;
.ok_or_else(|| UIErrors::FighterManifestHandleNotFound(player.fighter))?;
let manifest = manifests
.get(manifest)
.ok_or_else(|| UIErrors::FighterManifestNotFound(player.fighter))?;
fighter_uis.push(FighterInfoUI::build(manifest, player)); fighter_uis.push(FighterInfoUI::build(manifest, player));
} }
+6 -52
View File
@@ -1,17 +1,13 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{fighter::{attack::FighterDamage, manifest::{FighterManifest}}, match_loading::MatchPlayer, player::Player};
fighter::{attack::FighterDamage, manifest::FighterManifest}, match_loading::{MatchPlayer}, match_runtime::MatchState, player::Player, ui::stock_icon::BattleUIStockIcon,
};
#[derive(Component, FromTemplate)] #[derive(Component, FromTemplate)]
pub struct FighterInfoUI { pub struct FighterInfoUI {
percentage_text: Entity, percentage_text: Entity,
stock_icon_container: Entity,
_name_text: Entity, _name_text: Entity,
player_handle: usize, player_handle: usize,
visible_damage: FighterDamage, visible_damage: FighterDamage,
visible_stocks: u8,
} }
impl FighterInfoUI { impl FighterInfoUI {
@@ -20,7 +16,6 @@ impl FighterInfoUI {
bsn! { bsn! {
FighterInfoUI { FighterInfoUI {
percentage_text: #PercentageText, percentage_text: #PercentageText,
stock_icon_container: #StockIconContainer,
_name_text: #NameText, _name_text: #NameText,
player_handle: {match_player.handle} player_handle: {match_player.handle}
} }
@@ -30,10 +25,6 @@ impl FighterInfoUI {
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
} }
Children [ Children [
(
#StockIconContainer
Node
)
( (
#PercentageText #PercentageText
Text("0.00%") Text("0.00%")
@@ -50,54 +41,17 @@ impl FighterInfoUI {
} }
} }
pub fn update_fighter_info_damage( pub fn update_fighter_info_damage(fighters: Query<(&Player, &FighterDamage)>, ui_elements: Query<&mut FighterInfoUI>, mut commands: Commands) {
fighters: Query<(&Player, &FighterDamage)>,
ui_elements: Query<&mut FighterInfoUI>,
mut commands: Commands,
) {
for mut ui_element in ui_elements { for mut ui_element in ui_elements {
let Some((_, damage)) = fighters let Some((_, damage)) = fighters.iter().find(|(player, _)| {
.iter() player.handle == ui_element.player_handle
.find(|(player, _)| player.handle == ui_element.player_handle) }) else {
else {
continue; continue;
}; };
if *damage != ui_element.visible_damage { if *damage != ui_element.visible_damage {
commands commands.entity(ui_element.percentage_text).insert(Text(format!("{}%", damage)));
.entity(ui_element.percentage_text)
.insert(Text(format!("{}%", damage)));
ui_element.visible_damage = damage.clone(); ui_element.visible_damage = damage.clone();
} }
} }
} }
pub fn update_fighter_info_stocks(
match_state: Res<MatchState>,
ui_elements: Query<&mut FighterInfoUI>,
mut commands: Commands,
) {
for mut ui_element in ui_elements {
let handle = ui_element.player_handle;
let Some(match_player) = match_state.players.get(handle) else {
continue;
};
let Some(stocks) = match_player.stocks_remaining else {
continue;
};
if stocks != ui_element.visible_stocks {
commands
.entity(ui_element.stock_icon_container)
.despawn_children()
.with_children(|spawner| {
for _ in 0..stocks {
spawner.spawn_empty().apply_scene(BattleUIStockIcon::scene());
}
});
ui_element.visible_stocks = stocks;
}
}
}
+2 -72
View File
@@ -1,18 +1,10 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::AppState;
AppState,
match_loading::{MatchConfig, MatchRules},
match_runtime::{MatchState, PauseState},
};
pub mod battle_ui; pub mod battle_ui;
pub mod errors; pub mod errors;
pub mod fighter_info; pub mod fighter_info;
pub mod stock_icon;
#[derive(Component)]
pub struct PauseOverlay;
#[derive(Default)] #[derive(Default)]
pub struct InGameUIPlugin; pub struct InGameUIPlugin;
@@ -21,68 +13,6 @@ impl Plugin for InGameUIPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::InMatch), battle_ui::spawn_battle_ui) app.add_systems(OnEnter(AppState::InMatch), battle_ui::spawn_battle_ui)
.add_systems(OnExit(AppState::InMatch), battle_ui::cleanup_battle_ui) .add_systems(OnExit(AppState::InMatch), battle_ui::cleanup_battle_ui)
.add_systems(PostUpdate, fighter_info::update_fighter_info_damage) .add_systems(PostUpdate, fighter_info::update_fighter_info_damage);
.add_systems(
PostUpdate,
fighter_info::update_fighter_info_stocks.run_if(
resource_exists::<MatchState>
.and_then(resource_exists::<MatchConfig>)
.and_then(|match_config: Res<MatchConfig>| {
if let MatchRules::Stock { .. } = match_config.rules {
true
} else {
false
}
}),
),
)
.add_systems(Update, sync_pause_overlay);
}
}
pub fn sync_pause_overlay(
pause: Res<PauseState>,
overlay: Query<Entity, With<PauseOverlay>>,
mut commands: Commands,
) {
if !pause.is_changed() {
return;
}
if pause.paused {
if overlay.is_empty() {
commands.spawn((
PauseOverlay,
Node {
position_type: PositionType::Absolute,
left: px(0),
right: px(0),
top: px(0),
bottom: px(0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)),
ZIndex(100),
children![
(
Text::new("Paused"),
TextFont::from_font_size(crate::menus::style::H1_SIZE as f32),
TextColor(Color::WHITE),
),
(
Text::new("Press Esc or Start to resume"),
TextFont::from_font_size(crate::menus::style::TEXT_SIZE as f32),
TextColor(Color::WHITE),
),
],
));
}
} else {
for entity in &overlay {
commands.entity(entity).despawn();
}
} }
} }
-17
View File
@@ -1,17 +0,0 @@
use bevy::prelude::*;
#[derive(SceneComponent, Clone, FromTemplate)]
pub struct BattleUIStockIcon;
impl BattleUIStockIcon {
pub fn scene() -> impl Scene {
bsn! {
Node {
width: px(32),
height: px(32),
border_radius: BorderRadius::MAX,
}
BackgroundColor({bevy::color::palettes::css::RED})
}
}
}
-123
View File
@@ -1,123 +0,0 @@
"""LLDB summaries for Rust's `fixed` crate.
Load this file from LLDB with:
command script import /path/to/shinespark/tools/lldb_fixed.py
The implementation does not use floating-point arithmetic, so displayed values
are exact even for FixedI128/FixedU128 values.
"""
import re
try:
import lldb
except ImportError:
# Keep the pure parsing and formatting helpers testable without LLDB.
lldb = None
_FIXED_TYPE_RE = re.compile(r"(?:^|::)Fixed([IU])(8|16|32|64|128)<(.+)>$")
_DIRECT_TYPENUM_RE = re.compile(r"(?:^|::)U(\d+)$")
_TYPENUM_BIT_RE = re.compile(r"(?:^|::)B([01])(?=\s*[,>])")
def parse_fixed_type(type_name):
"""Return (signed, storage_bits, fractional_bits), or None."""
if not type_name:
return None
match = _FIXED_TYPE_RE.search(type_name.replace(" ", ""))
if match is None:
return None
signed = match.group(1) == "I"
storage_bits = int(match.group(2))
fractional_type = match.group(3)
direct = _DIRECT_TYPENUM_RE.search(fractional_type)
if direct is not None:
fractional_bits = int(direct.group(1))
else:
# typenum represents UInt<N, B> as a binary tree. Its textual Rust
# type lists the bits from most significant to least significant.
digits = _TYPENUM_BIT_RE.findall(fractional_type)
if not digits:
return None
fractional_bits = int("".join(digits), 2)
if fractional_bits > storage_bits:
return None
return signed, storage_bits, fractional_bits
def normalize_bits(raw_bits, signed, storage_bits):
"""Interpret a possibly unsigned LLDB integer using the fixed type's width."""
mask = (1 << storage_bits) - 1
raw_bits &= mask
if signed and raw_bits >= 1 << (storage_bits - 1):
raw_bits -= 1 << storage_bits
return raw_bits
def format_fixed(raw_bits, fractional_bits):
"""Format a scaled integer exactly as a decimal string."""
if fractional_bits == 0:
return str(raw_bits)
negative = raw_bits < 0
magnitude = -raw_bits if negative else raw_bits
whole = magnitude >> fractional_bits
remainder = magnitude & ((1 << fractional_bits) - 1)
if remainder == 0:
result = str(whole)
else:
# remainder / 2^n == (remainder * 5^n) / 10^n, which gives a finite,
# exact decimal representation without converting through a float.
decimal = str(remainder * 5**fractional_bits).zfill(fractional_bits)
result = f"{whole}.{decimal.rstrip('0')}"
return f"-{result}" if negative else result
def _parse_lldb_integer(value):
text = value.GetValue()
if text is not None:
try:
return int(text, 0)
except ValueError:
pass
# GetValueAsUnsigned is also useful on LLDB versions that do not render
# primitive values through GetValue. It is sufficient for all <= 64-bit
# fixed types; current LLDB versions render i128/u128 through GetValue.
return value.GetValueAsUnsigned()
def fixed_summary(value, _internal_dict):
layout = parse_fixed_type(value.GetTypeName())
if layout is None:
return None
signed, storage_bits, fractional_bits = layout
bits = value.GetChildMemberWithName("bits")
if not bits.IsValid():
return None
raw_bits = _parse_lldb_integer(bits)
raw_bits = normalize_bits(raw_bits, signed, storage_bits)
return format_fixed(raw_bits, fractional_bits)
def __lldb_init_module(debugger, _internal_dict):
# LLDB's type-name regex sees the concrete fixed type rather than project
# aliases such as FGi32 and FGWide.
debugger.HandleCommand(
"type summary add --python-function lldb_fixed.fixed_summary "
"--category Shinespark --regex "
"'^fixed::Fixed(I|U)(8|16|32|64|128)<.+>$'"
)
# A dedicated category makes the formatter work even when CodeLLDB has not
# enabled rustc's standard `Rust` formatter category yet.
debugger.HandleCommand("type category enable Shinespark")
-58
View File
@@ -1,58 +0,0 @@
import unittest
import lldb_fixed
class ParseFixedTypeTests(unittest.TestCase):
def test_direct_typenum_alias(self):
self.assertEqual(
lldb_fixed.parse_fixed_type("fixed::FixedI32<typenum::uint::U16>"),
(True, 32, 16),
)
def test_expanded_typenum(self):
type_name = (
"fixed::FixedI32<"
"typenum::uint::UInt<"
"typenum::uint::UInt<"
"typenum::uint::UInt<"
"typenum::uint::UInt<"
"typenum::uint::UInt<typenum::uint::UTerm, typenum::bit::B1>, "
"typenum::bit::B0>, typenum::bit::B0>, typenum::bit::B0>, "
"typenum::bit::B0>>"
)
self.assertEqual(lldb_fixed.parse_fixed_type(type_name), (True, 32, 16))
def test_unsigned_type(self):
self.assertEqual(
lldb_fixed.parse_fixed_type("fixed::FixedU64<typenum::uint::U24>"),
(False, 64, 24),
)
def test_unrelated_type(self):
self.assertIsNone(lldb_fixed.parse_fixed_type("core::primitive::i32"))
class FormatFixedTests(unittest.TestCase):
def test_q16_16(self):
self.assertEqual(lldb_fixed.format_fixed(3 << 15, 16), "1.5")
self.assertEqual(lldb_fixed.format_fixed(-(3 << 15), 16), "-1.5")
self.assertEqual(lldb_fixed.format_fixed(1, 16), "0.0000152587890625")
def test_integer_value_has_no_decimal_suffix(self):
self.assertEqual(lldb_fixed.format_fixed(12 << 16, 16), "12")
def test_q96_32(self):
raw = (1 << 100) + (1 << 31)
self.assertEqual(
lldb_fixed.format_fixed(raw, 32),
"295147905179352825856.5",
)
def test_twos_complement_normalization(self):
self.assertEqual(lldb_fixed.normalize_bits(0xFFFF8000, True, 32), -32768)
self.assertEqual(lldb_fixed.normalize_bits(0xFFFF8000, False, 32), 0xFFFF8000)
if __name__ == "__main__":
unittest.main()