Skip to content

Commit

Permalink
Scoreboards (#436)
Browse files Browse the repository at this point in the history
# Objective

- Adds support for Scoreboards via a new crate, `valence_scoreboard`
- closes #435

# TODO

- [x] When the scores update, only send the diff to clients
- [x] handle layer switching
- [x] docs
- [x] update the ctf example to show scoreboards
- [x] unit tests

<details>

<summary>Playground used for testing</summary>

```rust
use valence::client::despawn_disconnected_clients;
use valence::entity::pig::PigEntityBundle;
use valence::log::LogPlugin;
use valence::network::ConnectionMode;
use valence::prelude::*;
use valence::scoreboard::*;

#[allow(unused_imports)]
use crate::extras::*;

const SPAWN_Y: i32 = 64;

pub fn build_app(app: &mut App) {
    app.insert_resource(NetworkSettings {
        connection_mode: ConnectionMode::Offline,
        ..Default::default()
    })
    .add_plugins(DefaultPlugins.build().disable::<LogPlugin>())
    .add_systems(Startup, setup)
    .add_systems(EventLoopUpdate, toggle_gamemode_on_sneak)
    .add_systems(Update, (init_clients, despawn_disconnected_clients))
    .add_systems(Update, update_visible_layers)
    .run();
}

fn setup(
    mut commands: Commands,
    server: Res<Server>,
    biomes: Res<BiomeRegistry>,
    dimensions: Res<DimensionTypeRegistry>,
) {
    let mut layer = LayerBundle::new(ident!("overworld"), &dimensions, &biomes, &server);

    for z in -5..5 {
        for x in -5..5 {
            layer.chunk.insert_chunk([x, z], UnloadedChunk::new());
        }
    }

    for z in -25..25 {
        for x in -25..25 {
            layer
                .chunk
                .set_block([x, SPAWN_Y, z], BlockState::GRASS_BLOCK);
        }
    }

    let main_layer = commands.spawn(layer).id();
    let foo_layer = commands.spawn(EntityLayer::new(&server)).id();
    let bar_layer = commands.spawn(EntityLayer::new(&server)).id();

    let sample_ent = PigEntityBundle {
        position: Position([0.0, SPAWN_Y as f64 + 1.0, 0.0].into()),
        layer: EntityLayerId(main_layer),
        ..Default::default()
    };

    let foo_objective = ObjectiveBundle {
        name: Objective::new("foo"),
        display: ObjectiveDisplay("Foo".bold().color(Color::RED)),
        render_type: Default::default(),
        scores: ObjectiveScores::with_map([("foo".to_owned(), 3)]),
        position: ScoreboardPosition::Sidebar,
        layer: EntityLayerId(foo_layer),
    };

    let bar_objective = ObjectiveBundle {
        name: Objective::new("bar"),
        display: ObjectiveDisplay("Bar".bold().color(Color::BLUE)),
        render_type: Default::default(),
        scores: ObjectiveScores::with_map([("bar".to_owned(), 7)]),
        position: ScoreboardPosition::Sidebar,
        layer: EntityLayerId(bar_layer),
    };

    commands.spawn(sample_ent);
    commands.spawn(foo_objective);
    commands.spawn(bar_objective);

    let objectives = Objectives {
        main_layer,
        objectives: vec![foo_layer, bar_layer],
        current: 0,
    };

    commands.insert_resource(objectives);
}

fn init_clients(
    mut clients: Query<
        (
            &mut EntityLayerId,
            &mut VisibleChunkLayer,
            &mut VisibleEntityLayers,
            &mut Position,
            &mut GameMode,
        ),
        Added<Client>,
    >,
    layers: Query<Entity, (With<ChunkLayer>, With<EntityLayer>)>,
) {
    for (
        mut layer_id,
        mut visible_chunk_layer,
        mut visible_entity_layers,
        mut pos,
        mut game_mode,
    ) in &mut clients
    {
        let layer = layers.single();

        layer_id.0 = layer;
        visible_chunk_layer.0 = layer;
        visible_entity_layers.0.insert(layer);
        pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]);
        *game_mode = GameMode::Creative;
    }
}

#[derive(Resource)]
struct Objectives {
    main_layer: Entity,
    objectives: Vec<Entity>,
    current: usize,
}

fn update_visible_layers(
    mut clients: Query<&mut VisibleEntityLayers, With<Client>>,
    mut objectives: ResMut<Objectives>,
    server: Res<Server>,
) {
    let cycle_duration: usize = 20;
    if server.current_tick() % (cycle_duration as i64) != 0 {
        return;
    }
    objectives.current = (objectives.current + 1) % objectives.objectives.len();
    for mut visible_entity_layers in &mut clients {
        visible_entity_layers.0.clear();
        visible_entity_layers.0.insert(objectives.main_layer);
        visible_entity_layers
            .0
            .insert(objectives.objectives[objectives.current]);
    }
}
```

</details>
  • Loading branch information
dyc3 authored Aug 5, 2023
1 parent 80c1a93 commit 5c458ee
Show file tree
Hide file tree
Showing 12 changed files with 690 additions and 7 deletions.
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ default = [
"log",
"network",
"player_list",
"scoreboard",
"world_border",
"weather",
]
Expand All @@ -29,6 +30,7 @@ inventory = ["dep:valence_inventory"]
log = ["dep:bevy_log"]
network = ["dep:valence_network"]
player_list = ["dep:valence_player_list"]
scoreboard = ["dep:valence_scoreboard"]
world_border = ["dep:valence_world_border"]
weather = ["dep:valence_weather"]

Expand Down Expand Up @@ -56,6 +58,7 @@ valence_nbt.workspace = true
valence_network = { workspace = true, optional = true }
valence_player_list = { workspace = true, optional = true }
valence_registry.workspace = true
valence_scoreboard = { workspace = true, optional = true }
valence_world_border = { workspace = true, optional = true }
valence_packet.workspace = true
valence_weather = { workspace = true, optional = true }
Expand Down Expand Up @@ -181,6 +184,7 @@ valence_nbt = { path = "crates/valence_nbt", features = ["uuid"] }
valence_network.path = "crates/valence_network"
valence_player_list.path = "crates/valence_player_list"
valence_registry.path = "crates/valence_registry"
valence_scoreboard.path = "crates/valence_scoreboard"
valence_world_border.path = "crates/valence_world_border"
valence_boss_bar.path = "crates/valence_boss_bar"
valence_packet.path = "crates/valence_packet"
Expand Down
2 changes: 1 addition & 1 deletion crates/valence_core/src/uuid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use uuid::Uuid;
///
/// This component is expected to remain _unique_ and _constant_ during the
/// lifetime of the entity. The [`Default`] impl generates a new random UUID.
#[derive(Component, Copy, Clone, PartialEq, Eq, Debug)]
#[derive(Component, Copy, Clone, PartialEq, Eq, Debug, PartialOrd, Ord, Hash)]
pub struct UniqueId(pub Uuid);

/// Generates a new random UUID.
Expand Down
11 changes: 10 additions & 1 deletion crates/valence_packet/src/packets/play/scoreboard_display_s2c.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use bevy_ecs::prelude::Component;

use super::team_s2c::TeamColor;
use super::*;

Expand All @@ -8,11 +10,18 @@ pub struct ScoreboardDisplayS2c<'a> {
pub score_name: &'a str,
}

#[derive(Copy, Clone, PartialEq, Debug)]
/// Defines where a scoreboard is displayed.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Component, Default)]
pub enum ScoreboardPosition {
/// Display the scoreboard in the player list (the one you see when you
/// press tab), as a yellow number next to players' names.
List,
/// Display the scoreboard on the sidebar.
#[default]
Sidebar,
/// Display the scoreboard below players' name tags in the world.
BelowName,
/// Display the scoreboard on the sidebar, visible only to one team.
SidebarTeam(TeamColor),
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
use bevy_ecs::prelude::*;

use super::*;

#[derive(Clone, Debug, Encode, Decode, Packet)]
#[packet(id = packet_id::SCOREBOARD_OBJECTIVE_UPDATE_S2C)]
pub struct ScoreboardObjectiveUpdateS2c<'a> {
pub objective_name: &'a str,
pub mode: ObjectiveMode,
pub mode: ObjectiveMode<'a>,
}

#[derive(Clone, PartialEq, Debug, Encode, Decode)]
pub enum ObjectiveMode {
pub enum ObjectiveMode<'a> {
Create {
objective_display_name: Text,
objective_display_name: Cow<'a, Text>,
render_type: ObjectiveRenderType,
},
Remove,
Update {
objective_display_name: Text,
objective_display_name: Cow<'a, Text>,
render_type: ObjectiveRenderType,
},
}

#[derive(Copy, Clone, PartialEq, Eq, Debug, Encode, Decode)]
#[derive(Copy, Clone, PartialEq, Eq, Debug, Encode, Decode, Component, Default)]
pub enum ObjectiveRenderType {
/// Display the value as a number.
#[default]
Integer,
/// Display the value as hearts.
Hearts,
}
14 changes: 14 additions & 0 deletions crates/valence_scoreboard/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "valence_scoreboard"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy_app.workspace = true
bevy_ecs.workspace = true
valence_client.workspace = true
valence_core.workspace = true
valence_entity.workspace = true
valence_layer.workspace = true
valence_packet.workspace = true
tracing.workspace = true
23 changes: 23 additions & 0 deletions crates/valence_scoreboard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# valence_scoreboard

This crate provides functionality for creating and managing scoreboards. In Minecraft, a scoreboard references an [`Objective`], which is a mapping from strings to scores. Typically, the string is a player name, and the score is a number of points, but the string can be any arbitrary string <= 40 chars, and the score can be any integer.

In Valence, scoreboards obey the rules implied by [`EntityLayer`]s, meaning that every Objective must have an [`EntityLayerId`] associated with it. Scoreboards are only transmitted to clients if the [`EntityLayer`] is visible to the client.

To create a scoreboard, spawn an [`ObjectiveBundle`]. The [`Objective`] component represents the identifier that the client uses to reference the scoreboard.

Example:

```rust
# use bevy_ecs::prelude::*;
use valence_scoreboard::*;
use valence_core::text::IntoText;

fn spawn_scoreboard(mut commands: Commands) {
commands.spawn(ObjectiveBundle {
name: Objective::new("foo"),
display: ObjectiveDisplay("Foo".bold()),
..Default::default()
});
}
```
119 changes: 119 additions & 0 deletions crates/valence_scoreboard/src/components.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use std::collections::HashMap;

use bevy_ecs::prelude::*;
use valence_core::text::{IntoText, Text};
use valence_entity::EntityLayerId;
use valence_packet::packets::play::scoreboard_display_s2c::ScoreboardPosition;
use valence_packet::packets::play::scoreboard_objective_update_s2c::ObjectiveRenderType;

/// A string that identifies an objective. There is one scoreboard per
/// objective.It's generally not safe to modify this after it's been created.
/// Limited to 16 characters.
///
/// Directly analogous to an Objective's Name.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Component)]
pub struct Objective(pub(crate) String);

impl Objective {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
debug_assert!(
name.len() <= 16,
"Objective name {} is too long ({} > 16)",
name,
name.len()
);
Self(name)
}

pub fn name(&self) -> &str {
&self.0
}
}

/// Optional display name for an objective. If not present, the objective's name
/// is used.
#[derive(Debug, Clone, PartialEq, Component)]
pub struct ObjectiveDisplay(pub Text);

/// A mapping of keys to their scores.
#[derive(Debug, Clone, Component, Default)]
pub struct ObjectiveScores(pub(crate) HashMap<String, i32>);

impl ObjectiveScores {
pub fn new() -> Self {
Default::default()
}

pub fn with_map(map: impl Into<HashMap<String, i32>>) -> Self {
Self(map.into())
}

pub fn get(&self, key: &str) -> Option<&i32> {
self.0.get(key)
}

pub fn get_mut(&mut self, key: &str) -> Option<&mut i32> {
self.0.get_mut(key)
}

pub fn insert(&mut self, key: impl Into<String>, value: i32) -> Option<i32> {
self.0.insert(key.into(), value)
}
}

#[derive(Debug, Clone, Default, PartialEq, Component)]
pub struct OldObjectiveScores(pub(crate) HashMap<String, i32>);

impl OldObjectiveScores {
pub fn diff<'a>(&'a self, scores: &'a ObjectiveScores) -> Vec<&'a str> {
let mut diff = Vec::new();

for (key, value) in &self.0 {
if scores.0.get(key) != Some(value) {
diff.push(key.as_str());
}
}

let new_keys = scores
.0
.keys()
.filter(|key| !self.0.contains_key(key.as_str()))
.map(|key| key.as_str());

let removed_keys = self
.0
.keys()
.filter(|key| !scores.0.contains_key(key.as_str()))
.map(|key| key.as_str());

diff.extend(new_keys);
diff.extend(removed_keys);
diff
}
}

#[derive(Bundle)]
pub struct ObjectiveBundle {
pub name: Objective,
pub display: ObjectiveDisplay,
pub render_type: ObjectiveRenderType,
pub scores: ObjectiveScores,
pub old_scores: OldObjectiveScores,
pub position: ScoreboardPosition,
pub layer: EntityLayerId,
}

impl Default for ObjectiveBundle {
fn default() -> Self {
Self {
name: Objective::new(""),
display: ObjectiveDisplay("".into_text()),
render_type: Default::default(),
scores: Default::default(),
old_scores: Default::default(),
position: Default::default(),
layer: Default::default(),
}
}
}
Loading

0 comments on commit 5c458ee

Please sign in to comment.