Compare commits

..
9 Commits
Author SHA1 Message Date
EIREXE 2e06cbb4b7 Fix debug UI not working at all
CI / build-and-test (pull_request) Successful in 1m30s
CI / production-build (pull_request) Successful in 10m51s
2026-09-24 22:29:34 +02:00
EIREXE 9a3a1383ff Pin Blender for CI asset processing (#58)
CI / production-build (push) Successful in 13m8s
CI / build-and-test (push) Successful in 1m31s
Reviewed-on: #58
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-24 14:45:37 +00:00
EIREXE 1ba29b817b Implement .blend asset processor (#56)
CI / build-and-test (push) Successful in 1m51s
CI / production-build (push) Successful in 12m11s
Reviewed-on: #56
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-24 10:47:19 +00:00
EIREXE 2874d496ee Integrate lore CI (#57)
CI / build-and-test (push) Canceled after 0s
CI / production-build (push) Canceled after 0s
Reviewed-on: #57
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-24 10:30:02 +00:00
EIREXE f7bb8cc345 Remove UI multi cam setup (#54)
CI / build-and-test (push) Successful in 1m19s
CI / production-build (push) Successful in 9m38s
Reviewed-on: #54
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-20 17:34:11 +00:00
EIREXE 85236c4e0f Optimize rollback hot paths (#53)
CI / build-and-test (push) Successful in 1m16s
CI / production-build (push) Successful in 9m36s
## Summary

Fixes #20.

- Reuse fighter animation scratch and output buffers across rollback frames.
- Select air-collision hits in one pass without candidate allocations or sorting.
- Index contiguous replay input frames directly and binary-search checksums.
- Add regression tests for buffer reuse and replay lookup/validation.

Reviewed-on: #53
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-20 12:41:13 +00:00
EIREXE bcf1e114ef Add script to debug fixed point numbers in lldb
CI / build-and-test (push) Successful in 1m33s
CI / production-build (push) Successful in 9m46s
2026-09-20 13:35:43 +02:00
EIREXE 15f07edaaf Add stocks UI (#52)
CI / build-and-test (push) Successful in 1m16s
CI / production-build (push) Successful in 9m38s
Reviewed-on: #52
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-20 11:34:45 +00:00
EIREXE 1276562890 Rename pending match to match config and add match state (#51)
CI / build-and-test (push) Successful in 1m17s
CI / production-build (push) Successful in 9m37s
Reviewed-on: #51
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-20 11:13:15 +00:00
43 changed files with 1324 additions and 82414 deletions
+75
View File
@@ -15,6 +15,27 @@ 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 | \
@@ -58,6 +79,27 @@ 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 | \
@@ -87,8 +129,11 @@ 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 \
@@ -99,9 +144,39 @@ 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,6 +1,7 @@
/target /target
/.jackdaw /.jackdaw
imported_assets imported_assets
/assets/
# Local editor configuration # Local editor configuration
**/.vscode/ **/.vscode/
@@ -10,3 +11,7 @@ imported_assets
*_old.glb *_old.glb
/replay.json /replay.json
*.replay.tmp *.replay.tmp
# Python debugger formatter tests
__pycache__/
*.pyc
+1 -1
View File
@@ -12,7 +12,7 @@ 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", "bevy_settings"] } bevy = { version = "0.19", features = ["asset_processor", "basis-universal", "compressed_image_saver", "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.
+25 -8
View File
@@ -2,6 +2,15 @@
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
@@ -16,11 +25,6 @@ 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
@@ -28,6 +32,19 @@ cargo build --locked \
--profile production --no-default-features --features debug_features --profile production --no-default-features --features debug_features
``` ```
The asset preprocessing command must be run from the repository root. It ## Assets
populates `imported_assets/Default`, which is then consumed by the production
game without running the asset processor at startup. Game assets are managed in Lore rather than Git. Install the Lore CLI, then
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.

Before

Width:  |  Height:  |  Size: 31 KiB

@@ -1,12 +0,0 @@
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);
@@ -1,12 +0,0 @@
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);
@@ -1,13 +0,0 @@
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
@@ -1,219 +0,0 @@
(
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
@@ -1,3 +0,0 @@
(
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
@@ -1,128 +0,0 @@
(
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
@@ -1,36 +0,0 @@
(
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.

Before

Width:  |  Height:  |  Size: 42 KiB

-139
View File
@@ -1,139 +0,0 @@
(
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.

Before

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1,77 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

+30
View File
@@ -0,0 +1,30 @@
#!/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
@@ -0,0 +1,31 @@
#!/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
@@ -0,0 +1,111 @@
#!/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
@@ -0,0 +1,394 @@
//! 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
@@ -0,0 +1,80 @@
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()
+23 -4
View File
@@ -3,11 +3,9 @@
use std::{collections::HashMap, time::Duration}; use std::{collections::HashMap, time::Duration};
use bevy::{ use bevy::{
input::InputSystems, camera::{CameraOutputMode, visibility::RenderLayers}, input::InputSystems, prelude::*, render::render_resource::BlendState, settings::{ReflectSettingsGroup, SaveSettingsDeferred, SettingsGroup},
prelude::*,
settings::{ReflectSettingsGroup, SaveSettingsDeferred, SettingsGroup},
}; };
use bevy_egui::{EguiContexts, EguiPrimaryContextPass, egui}; use bevy_egui::{EguiContexts, EguiGlobalSettings, EguiPrimaryContextPass, PrimaryEguiContext, 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;
@@ -397,6 +395,7 @@ 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,
@@ -408,6 +407,26 @@ 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()
+4 -1
View File
@@ -2,8 +2,8 @@ use super::{
BAKED_ANIMATION_FPS, BAKED_ANIMATION_FPS,
runtime::{BakedAnimationClip, BakedFighterAnimations}, runtime::{BakedAnimationClip, BakedFighterAnimations},
}; };
use crate::math::fixed_transform::FixedMat4;
use crate::fighter::{animation::AnimKind, manifest::FighterManifest}; use crate::fighter::{animation::AnimKind, manifest::FighterManifest};
use crate::math::fixed_transform::FixedMat4;
use bevy::{ use bevy::{
asset::{ asset::{
AssetLoader, AssetPath, AsyncWriteExt, LoadContext, AssetLoader, AssetPath, AsyncWriteExt, LoadContext,
@@ -51,6 +51,9 @@ 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
+115 -6
View File
@@ -4,8 +4,8 @@ mod asset_baking;
mod runtime; mod runtime;
mod visual_binding; mod visual_binding;
pub use asset_baking::{BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin};
pub use crate::math::fixed_transform::FixedMat4; pub use crate::math::fixed_transform::FixedMat4;
pub use asset_baking::{BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin};
pub use runtime::{ pub use runtime::{
BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices, BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices,
update_fighter_bone_matrices, update_fighter_bone_matrices,
@@ -21,6 +21,7 @@ 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]
@@ -29,11 +30,7 @@ mod tests {
"../../../assets/fighters/test/test.fighter.ron" "../../../assets/fighters/test/test.fighter.ron"
)) ))
.unwrap(); .unwrap();
let (document, buffers, _) = gltf::import(concat!( let (document, buffers) = test_gltf(&manifest.animations);
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);
@@ -51,6 +48,104 @@ 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;
@@ -164,6 +259,20 @@ 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(&names, &parents, &pose, FGi32::ONE);
let capacities = matrices.buffer_capacities();
matrices.set_pose(&names, &parents, &pose, FGi32::ONE);
assert_eq!(capacities, matrices.buffer_capacities());
}
#[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;
+28 -8
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,12 +70,24 @@ 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>>,
} }
#[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(&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((
@@ -142,13 +154,19 @@ 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());
self.previous_frame_matrices.reserve(local.len());
self.local_matrices.reserve(local.len());
self.previous_local_matrices.reserve(local.len());
self.model_matrices.reserve(local.len());
std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices); std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices);
std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices); std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices);
self.local_matrices.clear(); self.local_matrices.clear();
self.local_matrices.extend_from_slice(local); self.local_matrices.extend_from_slice(local);
let mut model = vec![None; local.len()]; self.model_matrices.resize(local.len(), None);
self.model_matrices.fill(None);
for index in 0..local.len() { for index in 0..local.len() {
resolve_model_transform(index, parents, local, &mut model) resolve_model_transform(index, parents, local, &mut self.model_matrices)
.expect("baked bone hierarchy should be valid"); .expect("baked bone hierarchy should be valid");
} }
let model_scale = FixedMat4 { let model_scale = FixedMat4 {
@@ -159,10 +177,12 @@ impl FighterBoneMatrices {
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
], ],
}; };
self.matrices = model self.matrices.clear();
.into_iter() self.matrices.extend(
.map(|matrix| model_scale.mul(matrix.expect("baked bone hierarchy should be valid"))) self.model_matrices.iter().map(|matrix| {
.collect(); model_scale.mul(matrix.expect("baked bone hierarchy should be valid"))
}),
);
} }
} }
+24 -26
View File
@@ -50,31 +50,29 @@ pub fn air_collide_with_stage(
translation.0 + ecb.get_bottom_point(), translation.0 + ecb.get_bottom_point(),
); );
let mut intersect_results = vec![]; stage_collision
.stage_polys
for (poly_idx, poly) in stage_collision.stage_polys.iter().enumerate() {
if let Some(intersect_result) = poly.intersect_ray(ray_segment) {
intersect_results.push((
AirCollisionWithStageResult {
line_id: {
StageLineID {
polygon: poly_idx,
segment: intersect_result.segment_idx,
}
},
hit_position: intersect_result.position,
hit_normal: intersect_result.normal,
},
intersect_result
.position
.distance_squared(ray_segment.point2()),
));
}
}
intersect_results.sort_by(|(_, a), (_, b)| a.cmp(b));
intersect_results
.iter() .iter()
.last() .enumerate()
.map(|(val, _)| val.to_owned().clone()) .filter_map(|(poly_idx, poly)| {
poly.intersect_ray(ray_segment).map(|intersect_result| {
(
AirCollisionWithStageResult {
line_id: {
StageLineID {
polygon: poly_idx,
segment: intersect_result.segment_idx,
}
},
hit_position: intersect_result.position,
hit_normal: intersect_result.normal,
},
intersect_result
.position
.distance_squared(ray_segment.point2()),
)
})
})
.max_by(|(_, a), (_, b)| a.cmp(b))
.map(|(result, _)| result)
} }
+2
View File
@@ -38,6 +38,7 @@ use crate::{
}; };
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;
@@ -89,6 +90,7 @@ 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"),
+11 -6
View File
@@ -1,6 +1,7 @@
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")]
@@ -12,14 +13,18 @@ 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: ImageSamplerDescriptor { default_sampler: default_sampler.clone(),
address_mode_u: ImageAddressMode::Repeat, })
address_mode_v: ImageAddressMode::Repeat, .set(GltfPlugin {
address_mode_w: ImageAddressMode::Repeat, default_sampler,
..ImageSamplerDescriptor::linear() ..default()
},
}) })
.set(AssetPlugin { .set(AssetPlugin {
mode: Processed, mode: Processed,
+3 -14
View File
@@ -1,19 +1,8 @@
use bevy::{picking::pointer::PointerId, prelude::*}; use bevy::{picking::pointer::PointerId, prelude::*};
use crate::{ use crate::{
AppState, AppState, fighter::manifest::{FighterManifest, FighterManifestRegistry}, game_settings::CommonAssets, input::{LocalInputAssignments, LocalInputSource}, menus::{
fighter::manifest::{FighterManifest, FighterManifestRegistry}, 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},
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},
}, },
}; };
@@ -241,7 +230,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<IsDefaultUiCamera>>, cam: Single<&Camera, With<MenuCameraMarker>>,
ui_scale: Res<UiScale>, ui_scale: Res<UiScale>,
) -> Result { ) -> Result {
let camera = cam.into_inner(); let camera = cam.into_inner();
+3 -6
View File
@@ -9,11 +9,8 @@ use bevy::{
}; };
use crate::{ use crate::{
input::{LocalInputAssignments, LocalInputSource}, input::{LocalInputAssignments, LocalInputSource}, menus::{
menus::{ MenuCameraMarker, input::{MenuInput, MenuInputState}, scaling, style::FGUiStyle,
input::{MenuInput, MenuInputState},
scaling,
style::FGUiStyle,
}, },
}; };
@@ -181,7 +178,7 @@ pub fn cursor_input(
} }
pub fn update_cursor_transform( pub fn update_cursor_transform(
camera: Single<&Camera, With<IsDefaultUiCamera>>, camera: Single<&Camera, With<MenuCameraMarker>>,
query: Query<(&mut Node, &PointerLocation), With<FGMenuCursor>>, query: Query<(&mut Node, &PointerLocation), With<FGMenuCursor>>,
ui_scale: Res<UiScale>, ui_scale: Res<UiScale>,
) { ) {
+11 -14
View File
@@ -25,6 +25,8 @@ 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;
@@ -38,6 +40,7 @@ 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),
@@ -46,6 +49,12 @@ 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 {
@@ -70,10 +79,9 @@ pub fn despawn_menu(
if let Some(entity) = existing_menu { if let Some(entity) = existing_menu {
commands commands
.entity(entity.into_inner()) .entity(entity.into_inner())
.remove::<Camera2d>()
.remove::<Camera>()
.despawn(); .despawn();
} }
} }
pub fn spawn_menu( pub fn spawn_menu(
@@ -84,8 +92,6 @@ pub fn spawn_menu(
if let Some(entity) = existing_menu { if let Some(entity) = existing_menu {
commands commands
.entity(entity.into_inner()) .entity(entity.into_inner())
.remove::<Camera2d>()
.remove::<Camera>()
.despawn(); .despawn();
} }
commands.queue_spawn_scene(wrap_menu(menu)); commands.queue_spawn_scene(wrap_menu(menu));
@@ -113,16 +119,7 @@ impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems( app.add_systems(
OnEnter(AppState::MainMenu), OnEnter(AppState::MainMenu),
(build_menu::<MainMenu>, |mut commands: Commands| { build_menu::<MainMenu>,
commands.spawn((Camera2d, IsDefaultUiCamera));
commands.spawn((
Camera2d,
Camera {
order: 1,
..default()
},
));
}),
) )
.add_message::<character_select::PlayerSlotAssigned>() .add_message::<character_select::PlayerSlotAssigned>()
.init_resource::<navigation::UINavigationActionState>() .init_resource::<navigation::UINavigationActionState>()
+76 -20
View File
@@ -152,6 +152,20 @@ 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()
@@ -173,6 +187,21 @@ impl Replay {
handles.sort_unstable(); handles.sort_unstable();
handles != (0..handles.len()).collect::<Vec<_>>() 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)
}
} }
fn start_replay(mut commands: Commands, args: Res<Args>, loaded: Option<Res<LoadedReplay>>) { fn start_replay(mut commands: Commands, args: Res<Args>, loaded: Option<Res<LoadedReplay>>) {
@@ -210,11 +239,7 @@ 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 let Some(recorded) = replay.frame(frame.0) else {
.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;
@@ -235,21 +260,15 @@ 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
if let Some(expected) = replay && let Some(expected) = replay.checksum(frame.0)
.checksums && expected != checksum.0
.iter() {
.find(|value| value.frame == frame.0) error!(
.map(|value| value.checksum) "Replay desync at frame {}: expected {:X}, got {:X}",
{ frame.0, expected, checksum.0
if expected != checksum.0 { );
error!( *mismatch = true;
"Replay desync at frame {}: expected {:X}, got {:X}",
frame.0, expected, checksum.0
);
*mismatch = true;
}
}
} }
} }
@@ -407,4 +426,41 @@ mod tests {
"unsupported replay version 1" "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"
);
}
} }
+53 -7
View File
@@ -1,13 +1,17 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{fighter::{attack::FighterDamage, manifest::{FighterManifest}}, match_loading::MatchPlayer, player::Player}; use crate::{
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 {
@@ -16,6 +20,7 @@ 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}
} }
@@ -25,6 +30,10 @@ impl FighterInfoUI {
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
} }
Children [ Children [
(
#StockIconContainer
Node
)
( (
#PercentageText #PercentageText
Text("0.00%") Text("0.00%")
@@ -41,17 +50,54 @@ impl FighterInfoUI {
} }
} }
pub fn update_fighter_info_damage(fighters: Query<(&Player, &FighterDamage)>, ui_elements: Query<&mut FighterInfoUI>, mut commands: Commands) { pub fn update_fighter_info_damage(
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.iter().find(|(player, _)| { let Some((_, damage)) = fighters
player.handle == ui_element.player_handle .iter()
}) else { .find(|(player, _)| player.handle == ui_element.player_handle)
else {
continue; continue;
}; };
if *damage != ui_element.visible_damage { if *damage != ui_element.visible_damage {
commands.entity(ui_element.percentage_text).insert(Text(format!("{}%", damage))); commands
.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;
}
}
}
+21 -2
View File
@@ -1,10 +1,15 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::AppState; use crate::{
AppState,
match_loading::{MatchConfig, MatchRules},
match_runtime::MatchState,
};
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(Default)] #[derive(Default)]
pub struct InGameUIPlugin; pub struct InGameUIPlugin;
@@ -13,6 +18,20 @@ 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
}
}),
),
);
} }
} }
+17
View File
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,123 @@
"""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
@@ -0,0 +1,58 @@
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()