Compare commits

...

6 Commits

Author SHA1 Message Date
c489f32908 fix: audio and other subsystems being dropped in App::new(), use Box::leak to ensure static ownership 2025-08-12 13:08:08 -05:00
b91f70cf2f ci: add concurrency group to 'wasm' job to prevent concurrent page deployments 2025-08-12 11:56:03 -05:00
24a207be01 chore: use steps.$.outputs in build workflow, document 1.86.0 toolchain version 2025-08-12 11:41:29 -05:00
44e31d9b21 chore: sync lockfile, add lcov.info to .gitignore 2025-08-12 10:31:10 -05:00
dependabot[bot]
b67234765a chore(deps): bump actions/checkout from 4 to 5 in the dependencies group (#1)
Bumps the dependencies group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 09:27:54 -05:00
dependabot[bot]
d07498c30e chore(deps): bump the dependencies group with 5 updates (#2)
Bumps the dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `1.0.69` | `2.0.12` |
| [anyhow](https://github.com/dtolnay/anyhow) | `1.0.98` | `1.0.99` |
| [glam](https://github.com/bitshifter/glam-rs) | `0.30.4` | `0.30.5` |
| [serde_json](https://github.com/serde-rs/json) | `1.0.141` | `1.0.142` |
| [libc](https://github.com/rust-lang/libc) | `0.2.174` | `0.2.175` |


Updates `thiserror` from 1.0.69 to 2.0.12
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/1.0.69...2.0.12)

Updates `anyhow` from 1.0.98 to 1.0.99
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.98...1.0.99)

Updates `glam` from 0.30.4 to 0.30.5
- [Changelog](https://github.com/bitshifter/glam-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bitshifter/glam-rs/compare/0.30.4...0.30.5)

Updates `serde_json` from 1.0.141 to 1.0.142
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.141...v1.0.142)

Updates `libc` from 0.2.174 to 0.2.175
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.175/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.174...0.2.175)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.12
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: anyhow
  dependency-version: 1.0.99
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: glam
  dependency-version: 0.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: serde_json
  dependency-version: 1.0.142
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: libc
  dependency-version: 0.2.175
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xevion <xevion@xevion.dev>
2025-08-12 09:26:46 -05:00
12 changed files with 64 additions and 91 deletions

View File

@@ -30,7 +30,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Rust Toolchain - name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -64,15 +64,16 @@ jobs:
run: cargo build --release run: cargo build --release
- name: Acquire Package Version - name: Acquire Package Version
shell: bash id: get_version
shell: bash # required to prevent Windows runners from failing
run: | run: |
PACKAGE_VERSION=$(cargo metadata --format-version 1 --no-deps | jq '.packages[0].version' -r) set -euo pipefail # exit on error
echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $GITHUB_ENV echo "version=$(cargo metadata --format-version 1 --no-deps | jq '.packages[0].version' -r)" >> $GITHUB_OUTPUT
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: "pacman-${{ env.PACKAGE_VERSION }}-${{ matrix.target }}" name: "pacman-${{ steps.get_version.outputs.version }}-${{ matrix.target }}"
path: ./target/release/${{ matrix.artifact_name }} path: ./target/release/${{ matrix.artifact_name }}
retention-days: 7 retention-days: 7
if-no-files-found: error if-no-files-found: error
@@ -83,10 +84,13 @@ jobs:
permissions: permissions:
pages: write pages: write
id-token: write id-token: write
# concurrency group is used to prevent multiple page deployments from being attempted at the same time
concurrency:
group: ${{ github.workflow }}-wasm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Emscripten SDK - name: Setup Emscripten SDK
uses: pyodide/setup-emsdk@v15 uses: pyodide/setup-emsdk@v15
@@ -98,7 +102,7 @@ jobs:
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
target: wasm32-unknown-emscripten target: wasm32-unknown-emscripten
toolchain: 1.86.0 # we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues toolchain: 1.86.0
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master

View File

@@ -12,7 +12,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ emsdk/
rust-sdl2-emscripten/ rust-sdl2-emscripten/
assets/site/build.css assets/site/build.css
tailwindcss-* tailwindcss-*
lcov.info

42
Cargo.lock generated
View File

@@ -13,9 +13,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.98" version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
@@ -79,9 +79,9 @@ dependencies = [
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.30.4" version = "0.30.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a99dbe56b72736564cfa4b85bf9a33079f16ae8b74983ab06af3b1a3696b11" checksum = "f2d1aab06663bdce00d6ca5e5ed586ec8d18033a771906c993a1e3755b368d85"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -128,9 +128,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.174" version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]] [[package]]
name = "log" name = "log"
@@ -202,7 +202,7 @@ dependencies = [
"spin_sleep", "spin_sleep",
"strum", "strum",
"strum_macros", "strum_macros",
"thiserror 1.0.69", "thiserror",
"tracing", "tracing",
"tracing-error", "tracing-error",
"tracing-subscriber", "tracing-subscriber",
@@ -220,7 +220,7 @@ dependencies = [
"integer-sqrt", "integer-sqrt",
"num-traits", "num-traits",
"rustc-hash", "rustc-hash",
"thiserror 2.0.12", "thiserror",
] ]
[[package]] [[package]]
@@ -380,9 +380,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.141" version = "1.0.142"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -443,33 +443,13 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.12" version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [ dependencies = [
"thiserror-impl 2.0.12", "thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]

View File

@@ -15,11 +15,11 @@ spin_sleep = "1.3.2"
rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] } rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] }
pathfinding = "4.14" pathfinding = "4.14"
once_cell = "1.21.3" once_cell = "1.21.3"
thiserror = "1.0" thiserror = "2.0"
anyhow = "1.0" anyhow = "1.0"
glam = { version = "0.30.4", features = [] } glam = { version = "0.30.5", features = [] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.142"
smallvec = "1.15.1" smallvec = "1.15.1"
strum = "0.27.2" strum = "0.27.2"
strum_macros = "0.27.2" strum_macros = "0.27.2"
@@ -56,4 +56,4 @@ x86_64-apple-darwin = { triplet = "x64-osx" }
aarch64-apple-darwin = { triplet = "arm64-osx" } aarch64-apple-darwin = { triplet = "arm64-osx" }
[target.'cfg(target_os = "emscripten")'.dependencies] [target.'cfg(target_os = "emscripten")'.dependencies]
libc = "0.2.16" libc = "0.2.175"

View File

@@ -72,6 +72,8 @@ I wanted to hit a log of goals and features, making it a 'perfect' project that
Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process. Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process.
- We use rustc 1.86.0 for the build, due to bulk-memory-opt related issues on wasm32-unknown-emscripten.
- Technically, we could probably use stable or even nightly on desktop targets, but using different versions for different targets is a pain, mainly because of clippy warnings changing between versions.
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg. - Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
- For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git` - For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git`
- The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead. - The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead.

View File

@@ -1,3 +1,4 @@
[toolchain] [toolchain]
# we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues on wasm32-unknown-emscripten
channel = "1.86.0" channel = "1.86.0"
components = ["rustfmt", "llvm-tools-preview", "clippy"] components = ["rustfmt", "llvm-tools-preview", "clippy"]

View File

@@ -4,8 +4,9 @@ use glam::Vec2;
use sdl2::event::{Event, WindowEvent}; use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem};
use tracing::{error, event}; use tracing::{error, event};
use crate::error::{GameError, GameResult}; use crate::error::{GameError, GameResult};
@@ -14,26 +15,31 @@ use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game; use crate::game::Game;
use crate::platform::get_platform; use crate::platform::get_platform;
pub struct App<'a> { pub struct App {
game: Game, game: Game,
canvas: Canvas<Window>, canvas: Canvas<Window>,
event_pump: EventPump, event_pump: &'static mut EventPump,
backbuffer: Texture<'a>, backbuffer: Texture<'static>,
paused: bool, paused: bool,
last_tick: Instant, last_tick: Instant,
cursor_pos: Vec2, cursor_pos: Vec2,
} }
impl App<'_> { impl App {
pub fn new() -> GameResult<Self> { pub fn new() -> GameResult<Self> {
let sdl_context: &'static Sdl = Box::leak(Box::new(sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?));
let video_subsystem: &'static VideoSubsystem =
Box::leak(Box::new(sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?));
let audio_subsystem: &'static AudioSubsystem =
Box::leak(Box::new(sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?));
let ttf_context: &'static Sdl2TtfContext =
Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?));
let event_pump: &'static mut EventPump =
Box::leak(Box::new(sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?));
// Initialize platform-specific console // Initialize platform-specific console
get_platform().init_console()?; get_platform().init_console()?;
let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let window = video_subsystem let window = video_subsystem
.window( .window(
"Pac-Man", "Pac-Man",
@@ -50,18 +56,16 @@ impl App<'_> {
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) .set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator())); let texture_creator: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator()));
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem)?; let mut game = Game::new(texture_creator, ttf_context, audio_subsystem)?;
game.audio.set_mute(cfg!(debug_assertions)); // game.audio.set_mute(cfg!(debug_assertions));
let mut backbuffer = texture_creator_static let mut backbuffer = texture_creator
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest); backbuffer.set_scale_mode(ScaleMode::Nearest);
let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
// Initial draw // Initial draw
game.draw(&mut canvas, &mut backbuffer) game.draw(&mut canvas, &mut backbuffer)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;

View File

@@ -25,7 +25,7 @@ use crate::{
}, },
map::Map, map::Map,
texture::{ texture::{
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas}, sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
text::TextTexture, text::TextTexture,
}, },
}; };
@@ -59,7 +59,7 @@ pub struct Game {
impl Game { impl Game {
pub fn new( pub fn new(
texture_creator: &TextureCreator<WindowContext>, texture_creator: &'static TextureCreator<WindowContext>,
_ttf_context: &sdl2::ttf::Sdl2TtfContext, _ttf_context: &sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &sdl2::AudioSubsystem, _audio_subsystem: &sdl2::AudioSubsystem,
) -> GameResult<Game> { ) -> GameResult<Game> {
@@ -74,19 +74,16 @@ impl Game {
.ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?; .ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?;
let atlas_bytes = get_asset_bytes(Asset::Atlas)?; let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
let atlas_texture = unsafe { let atlas_texture = Box::leak(Box::new(texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
let texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { if e.to_string().contains("format") || e.to_string().contains("unsupported") {
if e.to_string().contains("format") || e.to_string().contains("unsupported") { GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}")))
GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}"))) } else {
} else { GameError::Texture(TextureError::LoadFailed(e.to_string()))
GameError::Texture(TextureError::LoadFailed(e.to_string())) }
} })?));
})?;
sprite::texture_to_static(texture)
};
let atlas_json = get_asset_bytes(Asset::AtlasJson)?; let atlas_json = get_asset_bytes(Asset::AtlasJson)?;
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json)?; let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json)?;
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); let atlas = SpriteAtlas::new(unsafe { std::mem::transmute_copy(atlas_texture) }, atlas_mapper);
let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png") let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/full.png".to_string())))?; .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/full.png".to_string())))?;
@@ -255,6 +252,7 @@ impl Game {
if !item.is_collected() { if !item.is_collected() {
item.collect(); item.collect();
self.score += item.get_score(); self.score += item.get_score();
self.audio.eat();
// Handle energizer effects // Handle energizer effects
if matches!(item.item_type, crate::entity::item::ItemType::Energizer) { if matches!(item.item_type, crate::entity::item::ItemType::Energizer) {

View File

@@ -138,20 +138,3 @@ impl SpriteAtlas {
self.default_color self.default_color
} }
} }
/// Converts a `Texture` to a `Texture<'static>` using transmute.
///
/// # Safety
///
/// This function is unsafe because it uses `std::mem::transmute` to change the lifetime
/// of the texture from the original lifetime to `'static`. The caller must ensure that:
///
/// - The original `Texture` will live for the entire duration of the program
/// - No references to the original texture exist that could become invalid
/// - The texture is not dropped while still being used as a `'static` reference
///
/// This is typically used when you have a texture that you know will live for the entire
/// program duration and need to store it in a structure that requires a `'static` lifetime.
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
std::mem::transmute(texture)
}

View File

@@ -25,7 +25,7 @@ fn test_fruit_kind_increasing_score() {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
kinds.sort_unstable_by_key(|(index, _)| *index); kinds.sort_unstable_by_key(|(index, _)| *index);
assert_eq!(kinds.len(), FruitKind::COUNT as usize); assert_eq!(kinds.len(), FruitKind::COUNT);
// Check that the score increases as expected // Check that the score increases as expected
for window in kinds.windows(2) { for window in kinds.windows(2) {