Skip to main content

steel_core/block_entity/entities/
beacon.rs

1//! Beacon block entity implementation.
2//!
3//! Beacons track the pyramid level and configured effects. The beam is re-scanned
4//! incrementally, [`BLOCKS_CHECK_PER_TICK`] blocks per tick, and every 80 game ticks the
5//! supporting pyramid is re-checked and the configured status effects are applied to nearby
6//! players while the beam is unobstructed.
7
8use std::{
9    mem,
10    str::FromStr as _,
11    sync::{Arc, Weak},
12};
13
14use glam::DVec3;
15use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
16use simdnbt::owned::NbtCompound;
17use steel_registry::blocks::block_state_ext::BlockStateExt as _;
18use steel_registry::mob_effect::MobEffectRef;
19use steel_registry::sound_event::SoundEventRef;
20use steel_registry::{
21    REGISTRY, RegistryExt, TaggedRegistryExt, sound_events, vanilla_block_entity_types,
22    vanilla_block_tags, vanilla_blocks, vanilla_entities, vanilla_mob_effects,
23};
24use steel_utils::color::ArgbColor;
25use steel_utils::locks::SyncMutex;
26use steel_utils::{
27    BlockPos, BlockStateId, Downcast as _, DowncastType, DowncastTypeKey, Identifier, WorldAabb,
28};
29
30use crate::behavior::BLOCK_BEHAVIORS;
31use crate::block_entity::{BlockEntity, BlockEntityBase};
32use crate::chunk::heightmap::HeightmapType;
33use crate::chunk::light::MAX_LIGHT_LEVEL;
34use crate::entity::{LivingEntity as _, MobEffectInstance};
35use crate::player::Player;
36use crate::world::World;
37
38const MAX_LEVELS: i32 = 4;
39
40const BEACON_TICK_INTERVAL: i64 = 80;
41
42/// Blocks of beam column scanned per tick.
43const BLOCKS_CHECK_PER_TICK: i32 = 10;
44
45const BASE_EFFECT_RANGE: f64 = 10.0;
46const EFFECT_RANGE_PER_LEVEL: f64 = 10.0;
47
48/// The four valid beacon effects, indexed by pyramid level tier.
49pub(crate) const BEACON_EFFECTS: [&[MobEffectRef]; 4] = [
50    &[vanilla_mob_effects::SPEED, vanilla_mob_effects::HASTE],
51    &[
52        vanilla_mob_effects::RESISTANCE,
53        vanilla_mob_effects::JUMP_BOOST,
54    ],
55    &[vanilla_mob_effects::STRENGTH],
56    &[vanilla_mob_effects::REGENERATION],
57];
58
59/// A contiguous run of beam blocks sharing one tint.
60// TODO: Expose a `getBeamSections` equivalent when server-side callers need it.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct BeamSection {
63    color: ArgbColor,
64    height: i32,
65}
66
67impl BeamSection {
68    const fn new(color: ArgbColor) -> Self {
69        Self { color, height: 1 }
70    }
71
72    const fn increase_height(&mut self) {
73        self.height += 1;
74    }
75}
76
77/// Mutable beacon state shared with the menu's data slots.
78pub struct BeaconState {
79    pub(crate) levels: i32,
80    pub(crate) primary_power: Option<MobEffectRef>,
81    pub(crate) secondary_power: Option<MobEffectRef>,
82    /// Y level reached by the in-progress beam scan; below `pos.y()` restarts the scan.
83    last_check_y: i32,
84    checking_beam_sections: Vec<BeamSection>,
85    /// Sections from the last completed scan. Emptiness is the beacon's activity gate.
86    pub(crate) beam_sections: Vec<BeamSection>,
87}
88
89impl BeaconState {
90    const fn new() -> Self {
91        Self {
92            levels: 0,
93            primary_power: None,
94            secondary_power: None,
95            last_check_y: i32::MIN,
96            checking_beam_sections: Vec::new(),
97            beam_sections: Vec::new(),
98        }
99    }
100
101    pub(crate) fn filter_effect(effect: Option<MobEffectRef>) -> Option<MobEffectRef> {
102        effect.filter(|effect| {
103            BEACON_EFFECTS
104                .iter()
105                .copied()
106                .flatten()
107                .any(|valid| valid.key == effect.key)
108        })
109    }
110
111    /// Rejects effect combinations the vanilla client UI cannot produce for `levels`: tiers
112    /// gate both slots, and a secondary additionally needs a full pyramid or the same effect.
113    pub(crate) fn validate_effects(
114        primary: Option<MobEffectRef>,
115        secondary: Option<MobEffectRef>,
116        levels: i32,
117    ) -> bool {
118        if secondary.is_some() && levels < MAX_LEVELS {
119            return false;
120        }
121        let primary_level = Self::required_levels_for(primary);
122        let secondary_level = Self::required_levels_for(secondary);
123        if primary_level > levels || secondary_level > levels {
124            return false;
125        }
126        if primary_level >= MAX_LEVELS {
127            return false;
128        }
129        secondary_level == 0
130            || secondary_level >= MAX_LEVELS
131            || primary.zip(secondary).is_some_and(|(p, s)| p.key == s.key)
132    }
133
134    /// Returns the 1-indexed tier that unlocks `effect`, `0` for `None`, or `i32::MAX` for
135    /// effects not in `BEACON_EFFECTS`.
136    fn required_levels_for(effect: Option<MobEffectRef>) -> i32 {
137        let Some(effect) = effect else {
138            return 0;
139        };
140        for (i, tier) in BEACON_EFFECTS.iter().enumerate() {
141            if tier.iter().any(|e| e.key == effect.key) {
142                return i as i32 + 1;
143            }
144        }
145        i32::MAX
146    }
147}
148
149/// Beacon block entity.
150pub struct BeaconBlockEntity {
151    base: Arc<BlockEntityBase>,
152    state: Arc<SyncMutex<BeaconState>>,
153}
154
155// SAFETY: This key is owned by Steel and uniquely identifies `BeaconBlockEntity`.
156unsafe impl DowncastType for BeaconBlockEntity {
157    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/beacon");
158}
159
160impl BeaconBlockEntity {
161    /// Creates a new beacon block entity.
162    #[must_use]
163    pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
164        let base = Arc::new(BlockEntityBase::new(
165            &vanilla_block_entity_types::BEACON,
166            level,
167            pos,
168            state,
169        ));
170        Self {
171            base,
172            state: Arc::new(SyncMutex::new(BeaconState::new())),
173        }
174    }
175
176    pub(crate) fn state(&self) -> Arc<SyncMutex<BeaconState>> {
177        Arc::clone(&self.state)
178    }
179
180    /// Handle the menu uses to mark this beacon changed.
181    pub(crate) fn base_handle(&self) -> Arc<BlockEntityBase> {
182        Arc::clone(&self.base)
183    }
184
185    pub(crate) fn play_sound(world: &World, pos: BlockPos, sound: SoundEventRef) {
186        world.play_block_sound(sound, pos, 1.0, 1.0, None);
187    }
188
189    /// Advances the beam scan by up to [`BLOCKS_CHECK_PER_TICK`] blocks.
190    ///
191    /// The scan starts at the beacon, which is itself a beam block.
192    fn advance_beam_scan(
193        state: &mut BeaconState,
194        world: &World,
195        pos: BlockPos,
196        last_set_block: i32,
197    ) {
198        let mut check_pos = if state.last_check_y < pos.y() {
199            state.checking_beam_sections.clear();
200            state.last_check_y = pos.y() - 1;
201            pos
202        } else {
203            BlockPos::new(pos.x(), state.last_check_y + 1, pos.z())
204        };
205
206        for _ in 0..BLOCKS_CHECK_PER_TICK {
207            if check_pos.y() > last_set_block {
208                break;
209            }
210
211            let block_state = world.get_block_state(check_pos);
212            let beam_color = BLOCK_BEHAVIORS
213                .get_behavior(block_state.get_block())
214                .beacon_beam_color(block_state);
215
216            if let Some(color) = beam_color {
217                let color = ArgbColor::new(color.texture_diffuse_color());
218                if state.checking_beam_sections.len() <= 1 {
219                    state.checking_beam_sections.push(BeamSection::new(color));
220                } else if let Some(last) = state.checking_beam_sections.last_mut() {
221                    if last.color == color {
222                        last.increase_height();
223                    } else {
224                        let blended = last.color.average(color);
225                        state.checking_beam_sections.push(BeamSection::new(blended));
226                    }
227                }
228            } else {
229                let opaque = block_state.get_block() != &vanilla_blocks::BEDROCK
230                    && block_state.get_light_dampening() >= MAX_LIGHT_LEVEL;
231                let Some(last) = state.checking_beam_sections.last_mut().filter(|_| !opaque) else {
232                    state.checking_beam_sections.clear();
233                    state.last_check_y = last_set_block;
234                    return;
235                };
236                last.increase_height();
237            }
238
239            check_pos = check_pos.above();
240            state.last_check_y += 1;
241        }
242    }
243
244    /// Recomputes the beacon's pyramid level.
245    fn update_base(world: &World, pos: BlockPos) -> i32 {
246        let mut levels = 0;
247        for step in 1..=MAX_LEVELS {
248            let layer_y = pos.y() - step;
249            if layer_y < world.get_min_y() {
250                break;
251            }
252
253            let mut valid = true;
254            'outer: for layer_x in (pos.x() - step)..=(pos.x() + step) {
255                for layer_z in (pos.z() - step)..=(pos.z() + step) {
256                    let state = world.get_block_state(BlockPos::new(layer_x, layer_y, layer_z));
257                    if !REGISTRY.blocks.is_in_tag(
258                        state.get_block(),
259                        &vanilla_block_tags::BlockTag::BEACON_BASE_BLOCKS,
260                    ) {
261                        valid = false;
262                        break 'outer;
263                    }
264                }
265            }
266
267            if !valid {
268                break;
269            }
270            levels = step;
271        }
272        levels
273    }
274
275    fn apply_effects(&self, world: &Arc<World>, pos: BlockPos, levels: i32) {
276        let (primary, secondary) = {
277            let state = self.state.lock();
278            (state.primary_power, state.secondary_power)
279        };
280        let Some(primary) = primary else {
281            return;
282        };
283
284        let range = f64::from(levels) * EFFECT_RANGE_PER_LEVEL + BASE_EFFECT_RANGE;
285        let base_amplifier =
286            i32::from(levels >= MAX_LEVELS && secondary.is_some_and(|s| s.key == primary.key));
287        let duration = (9 + levels * 2) * 20;
288
289        let world_height = f64::from(world.get_max_y() - world.get_min_y() + 1);
290        let min = DVec3::new(
291            f64::from(pos.x()) - range,
292            f64::from(pos.y()) - range,
293            f64::from(pos.z()) - range,
294        );
295        let max = DVec3::new(
296            f64::from(pos.x()) + 1.0 + range,
297            f64::from(pos.y()) + 1.0 + range + world_height,
298            f64::from(pos.z()) + 1.0 + range,
299        );
300        let aabb = WorldAabb::from_min_max(min, max);
301
302        for entity in world.get_entities_in_aabb_matching(&aabb, |entity| {
303            entity.entity_type() == &vanilla_entities::PLAYER && !entity.is_spectator()
304        }) {
305            let Some(player) = entity.downcast_ref::<Player>() else {
306                continue;
307            };
308            player.add_mob_effect(
309                MobEffectInstance::with_duration(primary, duration, base_amplifier)
310                    .with_ambient(true)
311                    .with_visible(true),
312            );
313
314            if let Some(secondary) =
315                secondary.filter(|s| levels >= MAX_LEVELS && s.key != primary.key)
316            {
317                player.add_mob_effect(
318                    MobEffectInstance::with_duration(secondary, duration, 0)
319                        .with_ambient(true)
320                        .with_visible(true),
321                );
322            }
323        }
324    }
325
326    fn store_effect(nbt: &mut NbtCompound, field: &str, effect: Option<MobEffectRef>) {
327        if let Some(effect) = effect {
328            nbt.insert(field, effect.key.to_string());
329        }
330    }
331
332    fn load_effect(nbt: &BorrowedNbtCompound<'_>, field: &str) -> Option<MobEffectRef> {
333        let nbt_view: NbtCompoundView<'_, '_> = nbt.into();
334        let key = Identifier::from_str(&nbt_view.string(field)?.to_string()).ok()?;
335        let effect = REGISTRY.mob_effects.by_key(&key)?;
336        BeaconState::filter_effect(Some(effect))
337    }
338}
339
340impl BlockEntity for BeaconBlockEntity {
341    fn base(&self) -> &BlockEntityBase {
342        &self.base
343    }
344
345    // TODO: Persist `CustomName` and `Lock` like Vanilla. Both need foundations Steel lacks: a
346    //       block-entity display name and a `LockCode` type.
347    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
348        let mut state = self.state.lock();
349        state.primary_power = Self::load_effect(nbt, "primary_effect");
350        state.secondary_power = Self::load_effect(nbt, "secondary_effect");
351    }
352
353    fn save_additional(&self, nbt: &mut NbtCompound) {
354        let state = self.state.lock();
355        Self::store_effect(nbt, "primary_effect", state.primary_power);
356        Self::store_effect(nbt, "secondary_effect", state.secondary_power);
357        nbt.insert("Levels", state.levels);
358    }
359
360    fn get_update_tag(&self) -> Option<NbtCompound> {
361        let mut nbt = NbtCompound::new();
362        {
363            let state = self.state.lock();
364            Self::store_effect(&mut nbt, "primary_effect", state.primary_power);
365            Self::store_effect(&mut nbt, "secondary_effect", state.secondary_power);
366            nbt.insert("Levels", state.levels);
367        }
368        Some(nbt)
369    }
370
371    fn tick(&self, world: &Arc<World>) {
372        let pos = self.get_block_pos();
373        let last_set_block = world.level_height_at(HeightmapType::WorldSurface, pos.x(), pos.z());
374        let is_interval_tick = world.game_time() % BEACON_TICK_INTERVAL == 0;
375
376        let (previous_levels, levels, had_beam, scan_complete) = {
377            let mut state = self.state.lock();
378            Self::advance_beam_scan(&mut state, world, pos, last_set_block);
379
380            let previous_levels = state.levels;
381            // Read before the swap below: this tick is gated on the last *completed* scan.
382            let had_beam = !state.beam_sections.is_empty();
383            if is_interval_tick && had_beam {
384                state.levels = Self::update_base(world, pos);
385            }
386
387            let scan_complete = state.last_check_y >= last_set_block;
388            if scan_complete {
389                state.last_check_y = world.get_min_y() - 1;
390                state.beam_sections = mem::take(&mut state.checking_beam_sections);
391            }
392
393            (previous_levels, state.levels, had_beam, scan_complete)
394        };
395
396        if is_interval_tick && levels > 0 && had_beam {
397            self.apply_effects(world, pos, levels);
398            Self::play_sound(world, pos, &sound_events::BLOCK_BEACON_AMBIENT);
399        }
400
401        if scan_complete {
402            // TODO: Trigger the CONSTRUCT_BEACON criterion for nearby players on activation once
403            //       Steel's shared advancement foundations exist.
404            let sound = match (previous_levels > 0, levels > 0) {
405                (false, true) => &sound_events::BLOCK_BEACON_ACTIVATE,
406                (true, false) => &sound_events::BLOCK_BEACON_DEACTIVATE,
407                _ => return,
408            };
409            Self::play_sound(world, pos, sound);
410        }
411    }
412
413    fn on_set_removed(&self) {
414        let Some(world) = self.get_level() else {
415            return;
416        };
417        Self::play_sound(
418            &world,
419            self.get_block_pos(),
420            &sound_events::BLOCK_BEACON_DEACTIVATE,
421        );
422    }
423}