steel_core/behavior/items/
bucket.rs1use crate::behavior::context::InteractionResult;
9use crate::behavior::item_utils::create_filled_result;
10use crate::behavior::{
11 BLOCK_BEHAVIORS, BlockStateBehaviorExt, FLUID_BEHAVIORS, ItemBehavior, UseItemContext,
12 pickup_waterlogged_block,
13};
14use crate::fluid::FluidStateExt;
15use crate::world::RaytraceAction;
16use steel_macros::item_behavior;
17use steel_registry::blocks::BlockRef;
18use steel_registry::blocks::block_state_ext::BlockStateExt;
19use steel_registry::blocks::properties::Direction;
20use steel_registry::fluid::FluidState;
21use steel_registry::item_stack::ItemStack;
22use steel_registry::level_events;
23use steel_registry::sound_events;
24use steel_registry::vanilla_blocks;
25use steel_registry::vanilla_fluids;
26use steel_registry::vanilla_game_events;
27use steel_registry::vanilla_items;
28use steel_utils::types::UpdateFlags;
29use steel_utils::{BlockPos, BlockStateId};
30
31use crate::world::game_event::GameEventContext;
32
33#[item_behavior]
35pub struct BucketItem {
36 #[json_arg(vanilla_blocks, json = "content", optional = "empty")]
37 fluid_block: Option<BlockRef>,
38}
39
40impl BucketItem {
41 #[must_use]
43 pub const fn new(fluid_block: Option<BlockRef>) -> Self {
44 Self { fluid_block }
45 }
46}
47
48impl ItemBehavior for BucketItem {
49 fn use_item(&self, context: &mut UseItemContext) -> InteractionResult {
50 match self.fluid_block {
51 None => use_empty_bucket(context),
52 Some(fluid_block) => use_filled_bucket(fluid_block, context),
53 }
54 }
55}
56
57fn filled_bucket_success_stack(context: &UseItemContext) -> ItemStack {
58 if context.player.has_infinite_materials() {
59 context
60 .inv
61 .with_item(|item| item.copy_with_count(item.count()))
62 } else {
63 ItemStack::new(&vanilla_items::BUCKET)
64 }
65}
66
67fn use_empty_bucket(context: &mut UseItemContext) -> InteractionResult {
68 let (start, end) = context.player.get_ray_endpoints();
69
70 let (hit_block, _) = context.world.raytrace(start, end, |pos, world| {
72 let state = world.get_block_state(pos);
73 let block = state.get_block();
74
75 if block == &vanilla_blocks::AIR {
76 return RaytraceAction::Pass;
77 }
78
79 let fluid_state = state.get_fluid_state();
80 if fluid_state.is_source() {
81 return RaytraceAction::ImmediateHit;
82 }
83 if !fluid_state.is_empty() {
85 return RaytraceAction::Pass;
86 }
87
88 RaytraceAction::CheckShape
89 });
90
91 let Some(hit_pos) = hit_block else {
93 return InteractionResult::Pass;
94 };
95
96 let hit_state = context.world.get_block_state(hit_pos);
97 let block_behavior = BLOCK_BEHAVIORS.get_behavior(hit_state.get_block());
98
99 if let Some(result) =
100 block_behavior.pickup_block(context.world, hit_pos, hit_state, Some(context.player))
101 {
102 if let Some(sound) = result.sound {
104 context
105 .world
106 .play_block_sound(sound, hit_pos, 1.0, 1.0, None);
107 }
108
109 create_filled_result(context, result.filled_bucket, true);
111 context.world.game_event(
112 &vanilla_game_events::FLUID_PICKUP,
113 hit_pos,
114 &GameEventContext::new(Some(context.player), None),
115 );
116
117 return InteractionResult::Success;
118 }
119
120 if let Some(result) = pickup_waterlogged_block(
122 block_behavior,
123 context.world,
124 hit_pos,
125 hit_state,
126 Some(context.player),
127 ) {
128 if let Some(sound) = result.sound {
129 context
130 .world
131 .play_block_sound(sound, hit_pos, 1.0, 1.0, None);
132 }
133
134 create_filled_result(context, result.filled_bucket, true);
135 context.world.game_event(
136 &vanilla_game_events::FLUID_PICKUP,
137 hit_pos,
138 &GameEventContext::new(Some(context.player), None),
139 );
140
141 return InteractionResult::Success;
142 }
143
144 InteractionResult::Fail
147}
148
149#[expect(
151 clippy::too_many_lines,
152 reason = "mirrors vanilla's emptyContents flow; splitting would obscure the sequential placement logic"
153)]
154fn use_filled_bucket(fluid_block: BlockRef, context: &mut UseItemContext) -> InteractionResult {
155 let (start, end) = context.player.get_ray_endpoints();
157 let (ray_block, ray_dir) = context.world.raytrace(start, end, |pos, world| {
158 let state = world.get_block_state(pos);
159 let block = state.get_block();
160 if block == &vanilla_blocks::AIR {
163 return RaytraceAction::Pass;
164 }
165 RaytraceAction::CheckShape
166 });
167
168 let (Some(clicked_pos), Some(direction)) = (ray_block, ray_dir) else {
170 return InteractionResult::Pass;
171 };
172
173 if !context.world.is_in_valid_bounds(clicked_pos) {
175 return InteractionResult::Fail;
176 }
177
178 let clicked_state = context.world.get_block_state(clicked_pos);
179 let is_sneaking = context.player.is_crouching();
180
181 let try_place_fluid = |pos: BlockPos, check_sneak: bool| -> bool {
185 if !context.world.is_in_valid_bounds(pos) {
186 return false;
187 }
188
189 let state = context.world.get_block_state(pos);
190 let fluid_state = state.get_fluid_state();
191
192 if check_sneak && is_sneaking && !state.get_block().config.is_air {
196 return false;
197 }
198
199 let is_water_bucket = fluid_block == &vanilla_blocks::WATER;
200 let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
201 let is_liquid_container = state.is_liquid_container();
202 let can_place_liquid = is_water_bucket
203 && is_liquid_container
204 && behavior.can_place_liquid_with_player(
205 state,
206 FluidState::source(&vanilla_fluids::WATER).fluid_id,
207 Some(context.player),
208 );
209 let can_replace = state.can_be_replaced_by_fluid(fluid_block);
210
211 if !can_replace && !can_place_liquid {
213 return false;
214 }
215
216 if is_water_bucket && context.world.dimension_type.water_evaporates {
220 context
221 .world
222 .level_event(level_events::PARTICLES_WATER_EVAPORATING, pos, 0, None);
223 return true;
224 }
225
226 if is_water_bucket && is_liquid_container {
228 let source_water = FluidState::source(&vanilla_fluids::WATER);
229 behavior.place_liquid(context.world, pos, state, source_water);
230 play_empty_sound_and_event(context, pos, true);
231 return true;
232 }
233
234 if can_replace {
236 let is_same_fluid = if is_water_bucket {
238 fluid_state.is_water()
239 } else {
240 fluid_state.is_lava()
241 };
242
243 if is_same_fluid && fluid_state.is_source() {
244 play_empty_sound_and_event(context, pos, is_water_bucket);
245 return true;
246 }
247
248 if !state.get_block().config.liquid && !state.get_block().config.is_air {
251 context.player.get_world().destroy_block(pos, true);
252 }
253
254 let fluid_state_to_place = fluid_block.default_state();
256 if context
257 .world
258 .set_block(pos, fluid_state_to_place, UpdateFlags::UPDATE_ALL_IMMEDIATE)
259 {
260 let fluid_ref = if is_water_bucket {
261 &vanilla_fluids::WATER
262 } else {
263 &vanilla_fluids::LAVA
264 };
265 let tick_delay = FLUID_BEHAVIORS
266 .get_behavior(fluid_ref)
267 .tick_delay(context.world);
268 context
269 .world
270 .schedule_fluid_tick_default(pos, fluid_ref, tick_delay);
271
272 play_empty_sound_and_event(context, pos, is_water_bucket);
273
274 return true;
275 }
276 }
277 false
278 };
279
280 let is_water_bucket = fluid_block == &vanilla_blocks::WATER;
285 let primary_pos =
286 filled_bucket_primary_pos(clicked_state, clicked_pos, direction, is_water_bucket);
287
288 if try_place_fluid(primary_pos, true) {
290 let result_stack = filled_bucket_success_stack(context);
291 create_filled_result(context, result_stack, true);
292 return InteractionResult::Success;
293 }
294
295 let secondary_pos = direction.relative(clicked_pos);
299 if try_place_fluid(secondary_pos, false) {
300 let result_stack = filled_bucket_success_stack(context);
301 create_filled_result(context, result_stack, true);
302 return InteractionResult::Success;
303 }
304
305 InteractionResult::Fail
306}
307
308fn play_empty_sound_and_event(context: &UseItemContext, pos: BlockPos, is_water_bucket: bool) {
309 let sound_event = if is_water_bucket {
310 &sound_events::ITEM_BUCKET_EMPTY
311 } else {
312 &sound_events::ITEM_BUCKET_EMPTY_LAVA
313 };
314 context
315 .world
316 .play_block_sound(sound_event, pos, 1.0, 1.0, None);
317 context.world.game_event(
318 &vanilla_game_events::FLUID_PLACE,
319 pos,
320 &GameEventContext::new(Some(context.player), None),
321 );
322}
323
324fn filled_bucket_primary_pos(
325 clicked_state: BlockStateId,
326 clicked_pos: BlockPos,
327 direction: Direction,
328 is_water_bucket: bool,
329) -> BlockPos {
330 if is_water_bucket && clicked_state.is_liquid_container() {
331 clicked_pos
332 } else {
333 direction.relative(clicked_pos)
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use crate::behavior::init_behaviors;
340 use steel_registry::{init_vanilla_registry, vanilla_blocks};
341
342 use super::*;
343
344 #[test]
345 fn filled_water_bucket_targets_non_waterlogged_liquid_container_in_place() {
346 init_vanilla_registry();
347 init_behaviors();
348
349 let kelp = vanilla_blocks::KELP.default_state();
350
351 assert_eq!(
352 filled_bucket_primary_pos(kelp, BlockPos::ZERO, Direction::North, true),
353 BlockPos::ZERO
354 );
355 assert_eq!(
356 filled_bucket_primary_pos(kelp, BlockPos::ZERO, Direction::North, false),
357 Direction::North.relative(BlockPos::ZERO)
358 );
359 }
360}