Skip to main content

rustapi/tools/
entity.rs

1use crate::hud::{Hud, HudMode};
2use crate::{
3    editor::{RUSTERIX, UNDOMANAGER},
4    prelude::*,
5};
6use MapEvent::*;
7use rusterix::EntityAction;
8use rusterix::prelude::*;
9use theframework::prelude::*;
10
11pub struct EntityTool {
12    id: TheId,
13    hud: Hud,
14
15    drag_state: Option<DragState>,
16    move_eps2: f32,
17}
18
19#[derive(Clone)]
20struct DragState {
21    target: DragTarget,
22    start_pos: Vec2<f32>,
23    changed: bool,
24    grab_offset: Vec2<f32>,
25}
26
27#[derive(Clone, Copy)]
28enum DragTarget {
29    Entity(Uuid),
30    Item(Uuid),
31}
32
33impl Tool for EntityTool {
34    fn new() -> Self
35    where
36        Self: Sized,
37    {
38        Self {
39            id: TheId::named("Entity Tool"),
40            hud: Hud::new(HudMode::Entity),
41
42            drag_state: None,
43            move_eps2: 0.01, // squared distance in map units to consider as movement
44        }
45    }
46
47    fn id(&self) -> TheId {
48        self.id.clone()
49    }
50    fn info(&self) -> String {
51        fl!("tool_entity")
52    }
53    fn icon_name(&self) -> String {
54        str!("treasure-chest")
55    }
56    fn accel(&self) -> Option<char> {
57        Some('Y')
58    }
59
60    fn help_url(&self) -> Option<String> {
61        Some("docs/creator/tools/entity".to_string())
62    }
63
64    fn tool_event(
65        &mut self,
66        tool_event: ToolEvent,
67        _ui: &mut TheUI,
68        _ctx: &mut TheContext,
69        _project: &mut Project,
70        server_ctx: &mut ServerContext,
71    ) -> bool {
72        match tool_event {
73            ToolEvent::Activate => {
74                server_ctx.curr_map_tool_type = MapToolType::General;
75
76                true
77            }
78            ToolEvent::DeActivate => true,
79            _ => false,
80        }
81    }
82
83    fn map_event(
84        &mut self,
85        map_event: MapEvent,
86        ui: &mut TheUI,
87        ctx: &mut TheContext,
88        map: &mut Map,
89        server_ctx: &mut ServerContext,
90    ) -> Option<ProjectUndoAtom> {
91        match map_event {
92            MapKey(c) => {
93                let dir = match c {
94                    'q' | 'Q' => Some(-1.0_f32),
95                    'e' | 'E' => Some(1.0_f32),
96                    _ => None,
97                };
98                if let Some(step) = dir
99                    && let Some(selected_id) = map.selected_entity_item
100                    && let Some(entity) = map
101                        .entities
102                        .iter_mut()
103                        .find(|e| e.creator_id == selected_id)
104                {
105                    let from = Self::snap_cardinal(entity.orientation);
106                    let to = if step < 0.0 {
107                        Vec2::new(-from.y, from.x)
108                    } else {
109                        Vec2::new(from.y, -from.x)
110                    };
111                    entity.orientation = to;
112                    server_ctx
113                        .rotated_entities
114                        .entry(selected_id)
115                        .and_modify(|entry| entry.1 = to)
116                        .or_insert((from, to));
117                    RUSTERIX.write().unwrap().set_dirty();
118                }
119            }
120            MapClicked(coord) => {
121                if self.hud.clicked(coord.x, coord.y, map, ui, ctx, server_ctx) {
122                    crate::editor::RUSTERIX.write().unwrap().set_dirty();
123                    return None;
124                }
125
126                if self.handle_game_click(coord, map) {
127                    return None;
128                }
129
130                if server_ctx.get_map_context() == MapContext::Region
131                    && let Some(hit) = self.pick_hit_for_coord(ui, server_ctx, map, coord)
132                {
133                    let click_pos = self
134                        .map_pos_unsnapped(ui, server_ctx, map, coord)
135                        .unwrap_or(hit.pos);
136
137                    map.clear_selection();
138                    map.selected_entity_item = Some(hit.id());
139
140                    let grab_offset = hit.pos - click_pos;
141
142                    self.drag_state = Some(DragState {
143                        target: hit.target,
144                        start_pos: hit.pos,
145                        changed: false,
146                        grab_offset,
147                    });
148
149                    match hit.target {
150                        DragTarget::Entity(id) => {
151                            if let Some(entity) = map.entities.iter().find(|e| e.creator_id == id) {
152                                server_ctx
153                                    .moved_entities
154                                    .entry(id)
155                                    .or_insert((entity.position, entity.position));
156                            }
157                        }
158                        DragTarget::Item(id) => {
159                            if let Some(item) = map.items.iter().find(|i| i.creator_id == id) {
160                                server_ctx
161                                    .moved_items
162                                    .entry(id)
163                                    .or_insert((item.position, item.position));
164                            }
165                        }
166                    }
167
168                    self.select_in_tree(ui, server_ctx, hit.id());
169                    ctx.ui.send(TheEvent::Custom(
170                        TheId::named("Map Selection Changed"),
171                        TheValue::Empty,
172                    ));
173                    RUSTERIX.write().unwrap().set_dirty();
174                }
175            }
176            MapUp(coord) => {
177                if self.handle_game_up(coord, map) {
178                    return None;
179                }
180
181                if let Some(state) = self.drag_state.take() {
182                    if state.changed {
183                        match state.target {
184                            DragTarget::Entity(id) => {
185                                if let Some(entity) =
186                                    map.entities.iter_mut().find(|e| e.creator_id == id)
187                                {
188                                    // Snap based on final dragged position, not pointer
189                                    let snapped = Self::snap_to_grid(
190                                        Vec2::new(entity.position.x, entity.position.z),
191                                        map.subdivisions,
192                                    );
193                                    entity.position.x = snapped.x;
194                                    entity.position.z = snapped.y;
195                                    server_ctx
196                                        .moved_entities
197                                        .entry(id)
198                                        .and_modify(|entry| entry.1 = entity.position)
199                                        .or_insert((entity.position, entity.position));
200                                }
201                            }
202                            DragTarget::Item(id) => {
203                                if let Some(item) =
204                                    map.items.iter_mut().find(|i| i.creator_id == id)
205                                {
206                                    let snapped = Self::snap_to_grid(
207                                        Vec2::new(item.position.x, item.position.z),
208                                        map.subdivisions,
209                                    );
210                                    item.position.x = snapped.x;
211                                    item.position.z = snapped.y;
212                                    server_ctx
213                                        .moved_items
214                                        .entry(id)
215                                        .and_modify(|entry| entry.1 = item.position)
216                                        .or_insert((item.position, item.position));
217                                }
218                            }
219                        }
220                    }
221                }
222
223                self.drag_state = None;
224            }
225            MapDragged(coord) => {
226                if let Some(_render_view) = ui.get_render_view("PolyView") {
227                    if let Some(mut state) = self.drag_state.take() {
228                        // Keep drag freeform; no snapping while moving
229                        let pointer_pos = self
230                            .map_pos_unsnapped(ui, server_ctx, map, coord)
231                            .unwrap_or(Vec2::new(0.0, 0.0));
232                        let mut drag_pos = pointer_pos + state.grab_offset;
233
234                        // Ignore tiny mouse jitter so a pure click doesn't register as a move
235                        let delta = drag_pos - state.start_pos;
236                        let moved = delta.x * delta.x + delta.y * delta.y > self.move_eps2;
237                        if !moved {
238                            drag_pos = state.start_pos;
239                        }
240
241                        match state.target {
242                            DragTarget::Entity(id) => {
243                                if let Some(entity) =
244                                    map.entities.iter_mut().find(|e| e.creator_id == id)
245                                {
246                                    if moved {
247                                        entity.position.x = drag_pos.x;
248                                        entity.position.z = drag_pos.y;
249                                        state.changed = true;
250                                    }
251
252                                    server_ctx
253                                        .moved_entities
254                                        .entry(id)
255                                        .and_modify(|entry| entry.1 = entity.position)
256                                        .or_insert((entity.position, entity.position));
257                                }
258                            }
259                            DragTarget::Item(id) => {
260                                if let Some(item) =
261                                    map.items.iter_mut().find(|i| i.creator_id == id)
262                                {
263                                    if moved {
264                                        item.position.x = drag_pos.x;
265                                        item.position.z = drag_pos.y;
266                                        state.changed = true;
267                                    }
268
269                                    server_ctx
270                                        .moved_items
271                                        .entry(id)
272                                        .and_modify(|entry| entry.1 = item.position)
273                                        .or_insert((item.position, item.position));
274                                }
275                            }
276                        }
277
278                        self.drag_state = Some(state);
279                    }
280                }
281            }
282            MapHover(coord) => {
283                if server_ctx.get_map_context() == MapContext::Region {
284                    if let Some(hit) = self.pick_hit_for_coord(ui, server_ctx, map, coord) {
285                        ctx.ui
286                            .send(TheEvent::SetStatusText(TheId::empty(), hit.status_text()));
287                    } else {
288                        ctx.ui
289                            .send(TheEvent::SetStatusText(TheId::empty(), "".into()));
290                    }
291                }
292
293                if let Some(render_view) = ui.get_render_view("PolyView") {
294                    let dim = *render_view.dim();
295                    server_ctx.hover = (None, None, None);
296                    let cp = server_ctx.local_to_map_cell(
297                        Vec2::new(dim.width as f32, dim.height as f32),
298                        Vec2::new(coord.x as f32, coord.y as f32),
299                        map,
300                        map.subdivisions,
301                    );
302                    server_ctx.hover_cursor = Some(cp);
303                }
304            }
305            _ => {}
306        }
307
308        None
309    }
310
311    fn draw_hud(
312        &mut self,
313        buffer: &mut TheRGBABuffer,
314        map: &mut Map,
315        ctx: &mut TheContext,
316        server_ctx: &mut ServerContext,
317        assets: &Assets,
318    ) {
319        let id = if !map.selected_linedefs.is_empty() {
320            Some(map.selected_linedefs[0])
321        } else {
322            None
323        };
324        self.hud.draw(buffer, map, ctx, server_ctx, id, assets);
325    }
326
327    fn handle_event(
328        &mut self,
329        event: &TheEvent,
330        ui: &mut TheUI,
331        ctx: &mut TheContext,
332        project: &mut Project,
333        server_ctx: &mut ServerContext,
334    ) -> bool {
335        #[allow(clippy::single_match)]
336        match event {
337            TheEvent::KeyCodeDown(TheValue::KeyCode(code)) => {
338                if *code == TheKeyCode::Delete {
339                    if let Some(render_view) = ui.get_render_view("PolyView") {
340                        if ctx.ui.has_focus(render_view.id()) {
341                            return self.delete_selected(ui, ctx, project, server_ctx);
342                        }
343                    }
344                }
345            }
346            _ => {}
347        }
348
349        false
350    }
351}
352
353impl EntityTool {
354    /// Convert screen coords to map space without snapping so clicking doesn't move things
355    fn map_pos_unsnapped(
356        &self,
357        ui: &mut TheUI,
358        server_ctx: &ServerContext,
359        map: &Map,
360        coord: Vec2<i32>,
361    ) -> Option<Vec2<f32>> {
362        if server_ctx.editor_view_mode != EditorViewMode::D2
363            && let Some(render_view) = ui.get_render_view("PolyView")
364        {
365            let dim = *render_view.dim();
366            let screen_uv = [
367                coord.x as f32 / dim.width as f32,
368                coord.y as f32 / dim.height as f32,
369            ];
370            let rusterix = RUSTERIX.write().unwrap();
371            if let Some((_, hit, _)) = rusterix.scene_handler.vm.pick_geo_id_at_uv(
372                dim.width as u32,
373                dim.height as u32,
374                screen_uv,
375                false,
376                true,
377            ) {
378                return Some(Vec2::new(hit.x, hit.z));
379            }
380            if let Some(hit) = server_ctx.hover_cursor_3d {
381                return Some(Vec2::new(hit.x, hit.z));
382            }
383        }
384
385        ui.get_render_view("PolyView").map(|render_view| {
386            let dim = *render_view.dim();
387            let grid_space_pos = Vec2::new(coord.x as f32, coord.y as f32)
388                - Vec2::new(dim.width as f32, dim.height as f32) / 2.0
389                - Vec2::new(map.offset.x, -map.offset.y);
390
391            grid_space_pos / map.grid_size
392        })
393    }
394
395    /// Snap a map position to the current grid/subdivision
396    fn snap_to_grid(pos: Vec2<f32>, subdivisions: f32) -> Vec2<f32> {
397        if subdivisions > 1.0 {
398            Vec2::new(
399                (pos.x * subdivisions).round() / subdivisions,
400                (pos.y * subdivisions).round() / subdivisions,
401            )
402        } else {
403            Vec2::new(pos.x.round(), pos.y.round())
404        }
405    }
406
407    /// Snap a direction to the nearest cardinal axis.
408    fn snap_cardinal(dir: Vec2<f32>) -> Vec2<f32> {
409        if dir.x.abs() >= dir.y.abs() {
410            if dir.x >= 0.0 {
411                Vec2::new(1.0, 0.0)
412            } else {
413                Vec2::new(-1.0, 0.0)
414            }
415        } else if dir.y >= 0.0 {
416            Vec2::new(0.0, 1.0)
417        } else {
418            Vec2::new(0.0, -1.0)
419        }
420    }
421
422    fn pick_hit(&self, map: &Map, pos: Vec2<f32>, radius2: f32) -> Option<Hit> {
423        if let Some(entity) = map.entities.iter().find(|e| {
424            let d = e.get_pos_xz() - pos;
425            d.x * d.x + d.y * d.y < radius2
426        }) {
427            return Some(Hit {
428                target: DragTarget::Entity(entity.creator_id),
429                name: entity
430                    .attributes
431                    .get_str("name")
432                    .map(|s| s.to_string())
433                    .unwrap_or_else(|| "Entity".into()),
434                pos: Vec2::new(entity.position.x, entity.position.z),
435            });
436        }
437
438        if let Some(item) = map.items.iter().find(|i| {
439            let d = i.get_pos_xz() - pos;
440            d.x * d.x + d.y * d.y < radius2
441        }) {
442            return Some(Hit {
443                target: DragTarget::Item(item.creator_id),
444                name: item
445                    .attributes
446                    .get_str("name")
447                    .map(|s| s.to_string())
448                    .unwrap_or_else(|| "Item".into()),
449                pos: Vec2::new(item.position.x, item.position.z),
450            });
451        }
452
453        None
454    }
455
456    fn pick_hit_for_coord(
457        &self,
458        ui: &mut TheUI,
459        server_ctx: &ServerContext,
460        map: &Map,
461        coord: Vec2<i32>,
462    ) -> Option<Hit> {
463        let pos = self.map_pos_unsnapped(ui, server_ctx, map, coord)?;
464        let radius2 = if server_ctx.editor_view_mode == EditorViewMode::D2 {
465            0.16
466        } else {
467            1.44
468        };
469        self.pick_hit(map, pos, radius2)
470    }
471
472    fn delete_selected(
473        &self,
474        ui: &mut TheUI,
475        ctx: &mut TheContext,
476        project: &mut Project,
477        server_ctx: &mut ServerContext,
478    ) -> bool {
479        let Some(selected) = project
480            .get_map_mut(server_ctx)
481            .and_then(|map| map.selected_entity_item)
482        else {
483            return false;
484        };
485
486        if let Some(region) = project.get_region_ctx_mut(server_ctx) {
487            if let Some(index) = region.characters.get_index_of(&selected) {
488                if let Some(character) = region.characters.get(&selected).cloned() {
489                    let atom = ProjectUndoAtom::RemoveRegionCharacterInstance(
490                        index,
491                        server_ctx.curr_region,
492                        character,
493                    );
494                    atom.redo(project, ui, ctx, server_ctx);
495                    UNDOMANAGER.write().unwrap().add_undo(atom, ctx);
496                    return true;
497                }
498            }
499
500            if let Some(index) = region.items.get_index_of(&selected) {
501                if let Some(item) = region.items.get(&selected).cloned() {
502                    let atom = ProjectUndoAtom::RemoveRegionItemInstance(
503                        index,
504                        server_ctx.curr_region,
505                        item,
506                    );
507                    atom.redo(project, ui, ctx, server_ctx);
508                    UNDOMANAGER.write().unwrap().add_undo(atom, ctx);
509                    return true;
510                }
511            }
512        }
513
514        false
515    }
516
517    fn select_in_tree(&self, ui: &mut TheUI, server_ctx: &ServerContext, id: Uuid) {
518        if let Some(tree_layout) = ui.get_tree_layout("Project Tree") {
519            if let Some(node) = tree_layout.get_node_by_id_mut(&server_ctx.curr_region) {
520                node.new_item_selected(&TheId::named_with_id("Region Content List Item", id));
521            }
522        }
523    }
524
525    fn handle_game_click(&self, coord: Vec2<i32>, map: &mut Map) -> bool {
526        let mut rusterix = RUSTERIX.write().unwrap();
527        let is_running = rusterix.server.state == rusterix::ServerState::Running;
528
529        if is_running {
530            if let Some(action) = rusterix.client.touch_down(coord, map) {
531                rusterix.server.local_player_action(action);
532            }
533            return true;
534        }
535        false
536    }
537
538    fn handle_game_up(&self, coord: Vec2<i32>, map: &mut Map) -> bool {
539        let mut rusterix = RUSTERIX.write().unwrap();
540        let is_running = rusterix.server.state == rusterix::ServerState::Running;
541
542        if is_running {
543            if let Some(action) = rusterix.client.touch_up(coord, map) {
544                rusterix.server.local_player_action(action);
545            }
546            rusterix.server.local_player_action(EntityAction::Off);
547            return true;
548        }
549        false
550    }
551}
552
553struct Hit {
554    target: DragTarget,
555    name: String,
556    pos: Vec2<f32>,
557}
558
559impl Hit {
560    fn id(&self) -> Uuid {
561        match self.target {
562            DragTarget::Entity(id) | DragTarget::Item(id) => id,
563        }
564    }
565
566    fn status_text(&self) -> String {
567        let prefix = match self.target {
568            DragTarget::Entity(_) => "Entity",
569            DragTarget::Item(_) => "Item",
570        };
571        format!("{prefix}: {}", self.name)
572    }
573}