diff --git a/.cargo/config b/.cargo/config index 6a9b75f52a..51f84ab9e9 100644 --- a/.cargo/config +++ b/.cargo/config @@ -4,4 +4,7 @@ rustflags = [ ] [alias] -generate = "run --package tools --" \ No newline at end of file +generate = "run --package tools --" +test-server = "run --bin veloren-server-cli --no-default-features" +server = "run --bin veloren-server-cli" + diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c3409292..7c03261b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Loading-Screen tips - Feeding animation for some animals - Power stat to weapons which affects weapon damage +- Add detection of entities under the cursor +- Functional group-system with exp-sharing and disabled damage to group members ### Changed @@ -80,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed window resizing on Mac OS X. - Dehardcoded many item variants - Tooltips avoid the mouse better and disappear when hovered +- Improved social window functions and visuals ### Removed diff --git a/Cargo.lock b/Cargo.lock index b0d9177941..709ab9ea4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4638,6 +4638,7 @@ dependencies = [ "ron", "serde", "serde_json", + "slab", "specs", "specs-idvs", "sum_type", diff --git a/assets/common/items/armor/back/short_0.ron b/assets/common/items/armor/back/short_0.ron index 2f7f4aa223..c53d6418dd 100644 --- a/assets/common/items/armor/back/short_0.ron +++ b/assets/common/items/armor/back/short_0.ron @@ -5,7 +5,7 @@ Item( ( kind: Back("Short0"), stats: ( - protection: Normal(0.0), + protection: Normal(0.2), ), ) ), diff --git a/assets/common/items/armor/back/short_1.ron b/assets/common/items/armor/back/short_1.ron new file mode 100644 index 0000000000..800c7ca622 --- /dev/null +++ b/assets/common/items/armor/back/short_1.ron @@ -0,0 +1,12 @@ +Item( + name: "Green Blanket", + description: "Keeps your shoulders warm.", + kind: Armor( + ( + kind: Back("Short1"), + stats: ( + protection: Normal(0.1), + ), + ) + ), +) diff --git a/assets/common/items/armor/neck/neck_1.ron b/assets/common/items/armor/neck/neck_1.ron new file mode 100644 index 0000000000..8f231cc3ce --- /dev/null +++ b/assets/common/items/armor/neck/neck_1.ron @@ -0,0 +1,12 @@ +Item( + name: "Gem of lesser Protection", + description: "Surrounded by a discrete magical glow.", + kind: Armor( + ( + kind: Neck("Neck1"), + stats: ( + protection: Normal(0.5), + ), + ) + ), +) diff --git a/assets/common/items/weapons/bow/starter_bow.ron b/assets/common/items/weapons/bow/starter_bow.ron index 749283b4dc..45b7357aa7 100644 --- a/assets/common/items/weapons/bow/starter_bow.ron +++ b/assets/common/items/weapons/bow/starter_bow.ron @@ -1,6 +1,6 @@ Item( name: "Uneven Bow", - description: "Someone carved his initials into it.", + description: "Someone carved their initials into it.", kind: Tool( ( kind: Bow("ShortBow0"), diff --git a/assets/common/loot_table.ron b/assets/common/loot_table.ron index 0b00677652..4d0a7e98bf 100644 --- a/assets/common/loot_table.ron +++ b/assets/common/loot_table.ron @@ -88,7 +88,7 @@ (0.50, "common.items.weapons.staff.starter_staff"), (0.35, "common.items.weapons.staff.bone_staff"), (0.15, "common.items.weapons.staff.amethyst_staff"), - (0.01, "common.items.weapons.staff.cultist_staff"), + //(0.01, "common.items.weapons.staff.cultist_staff"), // hammers (0.05, "common.items.weapons.hammer.starter_hammer"), (0.05, "common.items.weapons.hammer.wood_hammer-0"), @@ -230,6 +230,8 @@ (0.6, "common.items.armor.ring.ring_0"), // capes (0.6, "common.items.armor.back.short_0"), + (0.7, "common.items.armor.back.short_1"), // necks (0.6, "common.items.armor.neck.neck_0"), + (0.4, "common.items.armor.neck.neck_1"), ] diff --git a/assets/voxygen/element/buttons/group.png b/assets/voxygen/element/buttons/group.png new file mode 100644 index 0000000000..e059af9154 --- /dev/null +++ b/assets/voxygen/element/buttons/group.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55e113c52f38efe8fc5dcea32099f8821cc1c6b72810244412d8d4398ae36cac +size 2152 diff --git a/assets/voxygen/element/buttons/group_hover.png b/assets/voxygen/element/buttons/group_hover.png new file mode 100644 index 0000000000..c405b87a66 --- /dev/null +++ b/assets/voxygen/element/buttons/group_hover.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86380af53936c13c354d13616e662d9e25ca115d7857b883fda74a6cb7f57f74 +size 2243 diff --git a/assets/voxygen/element/buttons/group_press.png b/assets/voxygen/element/buttons/group_press.png new file mode 100644 index 0000000000..0fd69da245 --- /dev/null +++ b/assets/voxygen/element/buttons/group_press.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dda830676b251480e41ffe3cbd85a0531893bb299a429b0d26dce9edd5cdccc6 +size 2253 diff --git a/assets/voxygen/element/buttons/social_tab_active.png b/assets/voxygen/element/buttons/social_tab_active.png new file mode 100644 index 0000000000..854d4e224a --- /dev/null +++ b/assets/voxygen/element/buttons/social_tab_active.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a641284ee155e1c9e80fc05d214303195de4469b1ad372127db89244aa2e82b +size 470 diff --git a/assets/voxygen/element/buttons/social_tab_inactive.png b/assets/voxygen/element/buttons/social_tab_inactive.png new file mode 100644 index 0000000000..c974c14514 --- /dev/null +++ b/assets/voxygen/element/buttons/social_tab_inactive.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46fbea0721ccf1fea5963c2745b049fe441c1f25049af2882f10bea881fa67f1 +size 477 diff --git a/assets/voxygen/element/frames/enemybar_1.png b/assets/voxygen/element/frames/enemybar_1.png new file mode 100644 index 0000000000..37573662b8 --- /dev/null +++ b/assets/voxygen/element/frames/enemybar_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4cfb11db560dbda70ccf97b99905f01c21839f0ef2000a9d5427c7cc7d741f2 +size 257 diff --git a/assets/voxygen/element/frames/enemybar_bg_1.png b/assets/voxygen/element/frames/enemybar_bg_1.png new file mode 100644 index 0000000000..52266b3c1b --- /dev/null +++ b/assets/voxygen/element/frames/enemybar_bg_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71446655d69af498d4d32855b930150a530854ab56d9c1b64bf66c0340b4aab3 +size 183 diff --git a/assets/voxygen/element/frames/group_member_bg.png b/assets/voxygen/element/frames/group_member_bg.png new file mode 100644 index 0000000000..14c5f9b112 --- /dev/null +++ b/assets/voxygen/element/frames/group_member_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d9decab2383d59a28e70fe2a932687a861516d6a374f745c849dc6d9f456b41 +size 331 diff --git a/assets/voxygen/element/frames/group_member_frame.png b/assets/voxygen/element/frames/group_member_frame.png new file mode 100644 index 0000000000..f0043a0f5f --- /dev/null +++ b/assets/voxygen/element/frames/group_member_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3effc4f33ea986ae8de70beb0c13b3f85d0c488125a8be116a9848d67b348cb4 +size 329 diff --git a/assets/voxygen/element/icons/neck-0.png b/assets/voxygen/element/icons/neck-0.png index a7a1f6ca5e..ddd86a3dc3 100644 --- a/assets/voxygen/element/icons/neck-0.png +++ b/assets/voxygen/element/icons/neck-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:344387aec10b94e881c13b8d1ac5bb39aa085830c9b72a61f8b517c4e00684e5 -size 560 +oid sha256:efb33b5421f6ffd1dab50d382e141ca95f64a334c617263fe3de0f2895b7099e +size 555 diff --git a/assets/voxygen/element/icons/neck-1.png b/assets/voxygen/element/icons/neck-1.png new file mode 100644 index 0000000000..6da4108cb8 --- /dev/null +++ b/assets/voxygen/element/icons/neck-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53d3b5749504f4331518114b6e0f5b5b188ed8d150ab94dd2989f1388ca788ea +size 686 diff --git a/assets/voxygen/element/misc_bg/social_bg.png b/assets/voxygen/element/misc_bg/social_bg.png new file mode 100644 index 0000000000..6e57026acb --- /dev/null +++ b/assets/voxygen/element/misc_bg/social_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ee5e1459b78ef37b9947250401c8732a3011e6105c379260dc99feefcbfcef9 +size 6369 diff --git a/assets/voxygen/element/misc_bg/social_frame.png b/assets/voxygen/element/misc_bg/social_frame.png new file mode 100644 index 0000000000..f0216d61f0 --- /dev/null +++ b/assets/voxygen/element/misc_bg/social_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22ca239ce2b26552546465f85bc2c3c8499553cc06cfaff897f88b3a01e2af2e +size 5880 diff --git a/assets/voxygen/element/misc_bg/social_tab_active.png b/assets/voxygen/element/misc_bg/social_tab_active.png new file mode 100644 index 0000000000..854d4e224a --- /dev/null +++ b/assets/voxygen/element/misc_bg/social_tab_active.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a641284ee155e1c9e80fc05d214303195de4469b1ad372127db89244aa2e82b +size 470 diff --git a/assets/voxygen/element/misc_bg/social_tab_inactive.png b/assets/voxygen/element/misc_bg/social_tab_inactive.png new file mode 100644 index 0000000000..c974c14514 --- /dev/null +++ b/assets/voxygen/element/misc_bg/social_tab_inactive.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46fbea0721ccf1fea5963c2745b049fe441c1f25049af2882f10bea881fa67f1 +size 477 diff --git a/assets/voxygen/element/misc_bg/social_tab_online.png b/assets/voxygen/element/misc_bg/social_tab_online.png new file mode 100644 index 0000000000..f4f6f41589 --- /dev/null +++ b/assets/voxygen/element/misc_bg/social_tab_online.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d40285966edd6360907725a309a7bc090667c3685cb7f8cb734e41aa06ea15d +size 644 diff --git a/assets/voxygen/i18n/de_DE.ron b/assets/voxygen/i18n/de_DE.ron index de6f9305bb..f1f3f9da17 100644 --- a/assets/voxygen/i18n/de_DE.ron +++ b/assets/voxygen/i18n/de_DE.ron @@ -68,6 +68,8 @@ VoxygenLocalization( "common.none": "Kein", "common.error": "Fehler", "common.fatal_error": "Fataler Fehler", + "common.decline": "Ablehnen", + "common.you": "Ihr", /// End Common section // Message when connection to the server is lost @@ -306,7 +308,7 @@ magischen Gegenstände ergattern?"#, "hud.settings.unbound": "-", "hud.settings.reset_keybinds": "Auf Standard zurücksetzen", - "hud.social": "Sozial", + "hud.social": "Andere Spieler", "hud.social.online": "Online", "hud.social.friends": "Freunde", "hud.social.not_yet_available": "Noch nicht verfügbar", @@ -314,6 +316,23 @@ magischen Gegenstände ergattern?"#, "hud.social.play_online_fmt": "{nb_player} Spieler online", "hud.spell": "Zauber", + + "hud.social.name" : "Name", + "hud.social.level" : "Lvl", + "hud.social.zone" : "Gebiet", + + "hud.group": "Gruppe", + "hud.group.invite_to_join": "{name} lädt euch in seine Gruppe ein!", + "hud.group.invite": "Einladen", + "hud.group.kick": "Kicken", + "hud.group.assign_leader": "Anführer", + "hud.group.leave": "Gruppe Verlassen", + "hud.group.dead" : "Tot", + "hud.group.out_of_range": "Außer Reichweite", + "hud.group.add_friend": "Freund hinzufügen", + "hud.group.link_group": "Gruppen verbinden", + "hud.group.in_menu": "In Menü", + "hud.group.members": "Gruppen Mitglieder", "hud.crafting": "Herstellen", "hud.crafting.recipes": "Rezepte", @@ -376,6 +395,9 @@ magischen Gegenstände ergattern?"#, "gameinput.freelook": "Freie Sicht", "gameinput.autowalk": "Automatisch Laufen", "gameinput.dance": "Tanzen", + "gameinput.declinegroupinvite": "Ablehnen", + "gameinput.acceptgroupinvite": "Annehmen", + "gameinput.select": "Auswählen", /// End GameInput section diff --git a/assets/voxygen/i18n/en.ron b/assets/voxygen/i18n/en.ron index 0d4c6c2f9d..e57424b780 100644 --- a/assets/voxygen/i18n/en.ron +++ b/assets/voxygen/i18n/en.ron @@ -65,11 +65,13 @@ VoxygenLocalization( "common.create": "Create", "common.okay": "Okay", "common.accept": "Accept", + "common.decline": "Decline", "common.disclaimer": "Disclaimer", "common.cancel": "Cancel", "common.none": "None", "common.error": "Error", "common.fatal_error": "Fatal Error", + "common.you": "You", // Message when connection to the server is lost "common.connection_lost": r#"Connection lost! @@ -306,12 +308,16 @@ magically infused items?"#, "hud.settings.unbound": "None", "hud.settings.reset_keybinds": "Reset to Defaults", - "hud.social": "Social", - "hud.social.online": "Online", + "hud.social": "Other Players", + "hud.social.online": "Online:", "hud.social.friends": "Friends", "hud.social.not_yet_available": "Not yet available", "hud.social.faction": "Faction", "hud.social.play_online_fmt": "{nb_player} player(s) online", + "hud.social.name": "Name", + "hud.social.level": "Level", + "hud.social.zone": "Zone", + "hud.crafting": "Crafting", "hud.crafting.recipes": "Recipes", @@ -319,7 +325,20 @@ magically infused items?"#, "hud.crafting.craft": "Craft", "hud.crafting.tool_cata": "Requires:", - "hud.spell": "Spells", + "hud.group": "Group", + "hud.group.invite_to_join": "{name} invited you to their group!", + "hud.group.invite": "Invite", + "hud.group.kick": "Kick", + "hud.group.assign_leader": "Assign Leader", + "hud.group.leave": "Leave Group", + "hud.group.dead" : "Dead", + "hud.group.out_of_range": "Out of range", + "hud.group.add_friend": "Add to Friends", + "hud.group.link_group": "Link Groups", + "hud.group.in_menu": "In Menu", + "hud.group.members": "Group Members", + + "hud.spell": "Spells", "hud.free_look_indicator": "Free look active", "hud.auto_walk_indicator": "Auto walk active", @@ -377,7 +396,10 @@ magically infused items?"#, "gameinput.freelook": "Free Look", "gameinput.autowalk": "Auto Walk", "gameinput.dance": "Dance", - + "gameinput.select": "Select Entity", + "gameinput.acceptgroupinvite": "Accept Group Invite", + "gameinput.declinegroupinvite": "Decline Group Invite", + /// End GameInput section @@ -436,7 +458,8 @@ Protection "Press 'F1' to see all default keybindings.", "You can type /say or /s to only chat with players directly around you.", "You can type /region or /r to only chat with players a couple of hundred blocks around you.", - "To send private message type /tell followed by a player name and your message.", + "You can type /group or /g to only chat with players in your current group.", + "To send private messages type /tell followed by a player name and your message.", "NPCs with the same level can have a different difficulty.", "Look at the ground for food, chests and other loot!", "Inventory filled with food? Try crafting better food from it!", @@ -447,7 +470,9 @@ Protection "Press 'J' to dance. Party!", "Press 'L-Shift' to open your Glider and conquer the skies.", "Veloren is still in Pre-Alpha. We do our best to improve it every day!", - "If you want to join the Dev-Team or just have a chat with us join our Discord-Server.", + "If you want to join the Dev-Team or just have a chat with us join our Discord-Server.", + "You can toggle showing your amount of health on the healthbar in the settings.", + "In order to see your stats click the 'Stats' button in your inventory.", ], "npc.speech.villager_under_attack": [ "Help, I'm under attack!", diff --git a/assets/voxygen/item_image_manifest.ron b/assets/voxygen/item_image_manifest.ron index 5c71dde11b..51a2014214 100644 --- a/assets/voxygen/item_image_manifest.ron +++ b/assets/voxygen/item_image_manifest.ron @@ -1016,6 +1016,10 @@ Armor(Back("Short0")): VoxTrans( "voxel.armor.back.short-0", (0.0, 0.0, 0.0), (-90.0, 180.0, 0.0), 1.0, + ), + Armor(Back("Short1")): VoxTrans( + "voxel.armor.back.short-1", + (0.0, -2.0, 0.0), (-90.0, 180.0, 0.0), 1.0, ), Armor(Back("Admin")): VoxTrans( "voxel.armor.back.admin", @@ -1033,6 +1037,9 @@ Armor(Neck("Neck0")): Png( "element.icons.neck-0", ), + Armor(Neck("Neck1")): Png( + "element.icons.neck-1", + ), // Tabards Armor(Tabard("Admin")): Png( "element.icons.tabard_admin", diff --git a/assets/voxygen/voxel/armor/back/short-1.vox b/assets/voxygen/voxel/armor/back/short-1.vox new file mode 100644 index 0000000000..d1b1ebb8f1 --- /dev/null +++ b/assets/voxygen/voxel/armor/back/short-1.vox @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1676d3a052c16e41e9cf970db19815af6a7d3ef1c68bc2792c072be42aab6f22 +size 1344 diff --git a/assets/voxygen/voxel/humanoid_armor_back_manifest.ron b/assets/voxygen/voxel/humanoid_armor_back_manifest.ron index dcda59421c..7b31c4591d 100644 --- a/assets/voxygen/voxel/humanoid_armor_back_manifest.ron +++ b/assets/voxygen/voxel/humanoid_armor_back_manifest.ron @@ -15,6 +15,10 @@ "DungPurp0": ( vox_spec: ("armor.back.dung_purp-0", (-5.0, -1.0, -14.0)), color: None - ), + ), + "Short1": ( + vox_spec: ("armor.back.short-1", (-5.0, -1.0, -11.0)), + color: None + ), }, )) diff --git a/assets/voxygen/voxel/weapon/staff/firestaff_cultist.vox b/assets/voxygen/voxel/weapon/staff/firestaff_cultist.vox index 6957a98086..2c27ee0143 100644 --- a/assets/voxygen/voxel/weapon/staff/firestaff_cultist.vox +++ b/assets/voxygen/voxel/weapon/staff/firestaff_cultist.vox @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be510819e70d99ad4cb773de9a3cc8d343fc2d17236803e959b186a00020bd90 -size 27910 +oid sha256:39641c0dc427c30ac328101e3cbf7074e133d004a57ef964810b71d43779ddb8 +size 1464 diff --git a/assets/world/structure/dungeon/misc_entrance/tower-ruin.vox b/assets/world/structure/dungeon/misc_entrance/tower-ruin.vox index daf8e581ad..ff5a8cbf89 100644 --- a/assets/world/structure/dungeon/misc_entrance/tower-ruin.vox +++ b/assets/world/structure/dungeon/misc_entrance/tower-ruin.vox @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ec122d3d5f63af2210361c215845b582c15e15f9c7f1a9a65afcaf9d77accca -size 53856 +oid sha256:992e17484b853c44497b252edc5c52183e992a640217e851ca3b37477c63ff9a +size 52604 diff --git a/client/src/lib.rs b/client/src/lib.rs index 1603618a92..404c2a1686 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -17,13 +17,13 @@ use byteorder::{ByteOrder, LittleEndian}; use common::{ character::CharacterItem, comp::{ - self, ControlAction, ControlEvent, Controller, ControllerInputs, InventoryManip, - InventoryUpdateEvent, + self, group, ControlAction, ControlEvent, Controller, ControllerInputs, GroupManip, + InventoryManip, InventoryUpdateEvent, }, msg::{ - validate_chat_msg, ChatMsgValidationError, ClientMsg, ClientState, Notification, - PlayerInfo, PlayerListUpdate, RegisterError, RequestStateError, ServerInfo, ServerMsg, - MAX_BYTES_CHAT_MSG, + validate_chat_msg, ChatMsgValidationError, ClientMsg, ClientState, InviteAnswer, + Notification, PlayerInfo, PlayerListUpdate, RegisterError, RequestStateError, ServerInfo, + ServerMsg, MAX_BYTES_CHAT_MSG, }, recipe::RecipeBook, state::State, @@ -79,6 +79,15 @@ pub struct Client { recipe_book: RecipeBook, available_recipes: HashSet, + max_group_size: u32, + // Client has received an invite (inviter uid, time out instant) + group_invite: Option<(Uid, std::time::Instant, std::time::Duration)>, + group_leader: Option, + // Note: potentially representable as a client only component + group_members: HashMap, + // Pending invites that this client has sent out + pending_invites: HashSet, + _network: Network, participant: Option, singleton_stream: Stream, @@ -126,47 +135,49 @@ impl Client { let mut stream = block_on(participant.open(10, PROMISES_ORDERED | PROMISES_CONSISTENCY))?; // Wait for initial sync - let (state, entity, server_info, world_map, recipe_book) = block_on(async { - loop { - match stream.recv().await? { - ServerMsg::InitialSync { - entity_package, - server_info, - time_of_day, - world_map: (map_size, world_map), - recipe_book, - } => { - // TODO: Display that versions don't match in Voxygen - if &server_info.git_hash != *common::util::GIT_HASH { - warn!( - "Server is running {}[{}], you are running {}[{}], versions might \ - be incompatible!", - server_info.git_hash, - server_info.git_date, - common::util::GIT_HASH.to_string(), - common::util::GIT_DATE.to_string(), - ); - } + let (state, entity, server_info, world_map, recipe_book, max_group_size) = block_on( + async { + loop { + match stream.recv().await? { + ServerMsg::InitialSync { + entity_package, + server_info, + time_of_day, + max_group_size, + world_map: (map_size, world_map), + recipe_book, + } => { + // TODO: Display that versions don't match in Voxygen + if &server_info.git_hash != *common::util::GIT_HASH { + warn!( + "Server is running {}[{}], you are running {}[{}], versions \ + might be incompatible!", + server_info.git_hash, + server_info.git_date, + common::util::GIT_HASH.to_string(), + common::util::GIT_DATE.to_string(), + ); + } - debug!("Auth Server: {:?}", server_info.auth_provider); + debug!("Auth Server: {:?}", server_info.auth_provider); - // Initialize `State` - let mut state = State::default(); - // Client-only components - state - .ecs_mut() - .register::>(); + // Initialize `State` + let mut state = State::default(); + // Client-only components + state + .ecs_mut() + .register::>(); - let entity = state.ecs_mut().apply_entity_package(entity_package); - *state.ecs_mut().write_resource() = time_of_day; + let entity = state.ecs_mut().apply_entity_package(entity_package); + *state.ecs_mut().write_resource() = time_of_day; - assert_eq!(world_map.len(), (map_size.x * map_size.y) as usize); - let mut world_map_raw = - vec![0u8; 4 * world_map.len()/*map_size.x * map_size.y*/]; - LittleEndian::write_u32_into(&world_map, &mut world_map_raw); - debug!("Preparing image..."); - let world_map = Arc::new( - image::DynamicImage::ImageRgba8({ + assert_eq!(world_map.len(), (map_size.x * map_size.y) as usize); + let mut world_map_raw = + vec![0u8; 4 * world_map.len()/*map_size.x * map_size.y*/]; + LittleEndian::write_u32_into(&world_map, &mut world_map_raw); + debug!("Preparing image..."); + let world_map = Arc::new( + image::DynamicImage::ImageRgba8({ // Should not fail if the dimensions are correct. let world_map = image::ImageBuffer::from_raw(map_size.x, map_size.y, world_map_raw); @@ -175,24 +186,26 @@ impl Client { // Flip the image, since Voxygen uses an orientation where rotation from // positive x axis to positive y axis is counterclockwise around the z axis. .flipv(), - ); - debug!("Done preparing image..."); + ); + debug!("Done preparing image..."); - break Ok(( - state, - entity, - server_info, - (world_map, map_size), - recipe_book, - )); - }, - ServerMsg::TooManyPlayers => break Err(Error::TooManyPlayers), - err => { - warn!("whoops, server mad {:?}, ignoring", err); - }, + break Ok(( + state, + entity, + server_info, + (world_map, map_size), + recipe_book, + max_group_size, + )); + }, + ServerMsg::TooManyPlayers => break Err(Error::TooManyPlayers), + err => { + warn!("whoops, server mad {:?}, ignoring", err); + }, + } } - } - })?; + }, + )?; stream.send(ClientMsg::Ping)?; @@ -213,6 +226,12 @@ impl Client { recipe_book, available_recipes: HashSet::default(), + max_group_size, + group_invite: None, + group_leader: None, + group_members: HashMap::new(), + pending_invites: HashSet::new(), + _network: network, participant: Some(participant), singleton_stream: stream, @@ -375,7 +394,7 @@ impl Client { } pub fn pick_up(&mut self, entity: EcsEntity) { - if let Some(uid) = self.state.ecs().read_storage::().get(entity).copied() { + if let Some(uid) = self.state.read_component_copied(entity) { self.singleton_stream .send(ClientMsg::ControlEvent(ControlEvent::InventoryManip( InventoryManip::Pickup(uid), @@ -424,6 +443,72 @@ impl Client { .unwrap(); } + pub fn max_group_size(&self) -> u32 { self.max_group_size } + + pub fn group_invite(&self) -> Option<(Uid, std::time::Instant, std::time::Duration)> { + self.group_invite + } + + pub fn group_info(&self) -> Option<(String, Uid)> { + self.group_leader.map(|l| ("Group".into(), l)) // TODO + } + + pub fn group_members(&self) -> &HashMap { &self.group_members } + + pub fn pending_invites(&self) -> &HashSet { &self.pending_invites } + + pub fn send_group_invite(&mut self, invitee: Uid) { + self.singleton_stream + .send(ClientMsg::ControlEvent(ControlEvent::GroupManip( + GroupManip::Invite(invitee), + ))) + .unwrap() + } + + pub fn accept_group_invite(&mut self) { + // Clear invite + self.group_invite.take(); + self.singleton_stream + .send(ClientMsg::ControlEvent(ControlEvent::GroupManip( + GroupManip::Accept, + ))) + .unwrap(); + } + + pub fn decline_group_invite(&mut self) { + // Clear invite + self.group_invite.take(); + self.singleton_stream + .send(ClientMsg::ControlEvent(ControlEvent::GroupManip( + GroupManip::Decline, + ))) + .unwrap(); + } + + pub fn leave_group(&mut self) { + self.singleton_stream + .send(ClientMsg::ControlEvent(ControlEvent::GroupManip( + GroupManip::Leave, + ))) + .unwrap(); + } + + pub fn kick_from_group(&mut self, uid: Uid) { + self.singleton_stream + .send(ClientMsg::ControlEvent(ControlEvent::GroupManip( + GroupManip::Kick(uid), + ))) + .unwrap(); + } + + pub fn assign_group_leader(&mut self, uid: Uid) { + self.singleton_stream + .send(ClientMsg::ControlEvent(ControlEvent::GroupManip( + GroupManip::AssignLeader(uid), + ))) + .unwrap(); + } + pub fn is_mounted(&self) -> bool { self.state .ecs() @@ -433,7 +518,7 @@ impl Client { } pub fn mount(&mut self, entity: EcsEntity) { - if let Some(uid) = self.state.ecs().read_storage::().get(entity).copied() { + if let Some(uid) = self.state.read_component_copied(entity) { self.singleton_stream .send(ClientMsg::ControlEvent(ControlEvent::Mount(uid))) .unwrap(); @@ -690,6 +775,13 @@ impl Client { frontend_events.append(&mut self.handle_new_messages()?); // 3) Update client local data + // Check if the group invite has timed out and remove if so + if self + .group_invite + .map_or(false, |(_, timeout, dur)| timeout.elapsed() > dur) + { + self.group_invite = None; + } // 4) Tick the client's LocalState self.state.tick(dt, add_foreign_systems, true); @@ -935,7 +1027,102 @@ impl Client { ); } }, - + ServerMsg::GroupUpdate(change_notification) => { + use comp::group::ChangeNotification::*; + // Note: we use a hashmap since this would not work with entities outside + // the view distance + match change_notification { + Added(uid, role) => { + // Check if this is a newly formed group by looking for absence of + // other non pet group members + if !matches!(role, group::Role::Pet) + && !self + .group_members + .values() + .any(|r| !matches!(r, group::Role::Pet)) + { + frontend_events.push(Event::Chat(comp::ChatType::Meta.chat_msg( + "Type /g or /group to chat with your group members", + ))); + } + if let Some(player_info) = self.player_list.get(&uid) { + frontend_events.push(Event::Chat( + comp::ChatType::GroupMeta("Group".into()).chat_msg(format!( + "[{}] joined group", + player_info.player_alias + )), + )); + } + if self.group_members.insert(uid, role) == Some(role) { + warn!( + "Received msg to add uid {} to the group members but they \ + were already there", + uid + ); + } + }, + Removed(uid) => { + if let Some(player_info) = self.player_list.get(&uid) { + frontend_events.push(Event::Chat( + comp::ChatType::GroupMeta("Group".into()).chat_msg(format!( + "[{}] left group", + player_info.player_alias + )), + )); + } + if self.group_members.remove(&uid).is_none() { + warn!( + "Received msg to remove uid {} from group members but by they \ + weren't in there!", + uid + ); + } + }, + NewLeader(leader) => { + self.group_leader = Some(leader); + }, + NewGroup { leader, members } => { + self.group_leader = Some(leader); + self.group_members = members.into_iter().collect(); + // Currently add/remove messages treat client as an implicit member + // of the group whereas this message explicitly includes them so to + // be consistent for now we will remove the client from the + // received hashset + if let Some(uid) = self.uid() { + self.group_members.remove(&uid); + } + }, + NoGroup => { + self.group_leader = None; + self.group_members = HashMap::new(); + }, + } + }, + ServerMsg::GroupInvite { inviter, timeout } => { + self.group_invite = Some((inviter, std::time::Instant::now(), timeout)); + }, + ServerMsg::InvitePending(uid) => { + if !self.pending_invites.insert(uid) { + warn!("Received message about pending invite that was already pending"); + } + }, + ServerMsg::InviteComplete { target, answer } => { + if !self.pending_invites.remove(&target) { + warn!( + "Received completed invite message for invite that was not in the \ + list of pending invites" + ) + } + // TODO: expose this as a new event variant instead of going + // through the chat + let msg = match answer { + // TODO: say who accepted/declined/timed out the invite + InviteAnswer::Accepted => "Invite accepted", + InviteAnswer::Declined => "Invite declined", + InviteAnswer::TimedOut => "Invite timed out", + }; + frontend_events.push(Event::Chat(comp::ChatType::Meta.chat_msg(msg))); + }, ServerMsg::Ping => { self.singleton_stream.send(ClientMsg::Pong)?; }, @@ -976,7 +1163,7 @@ impl Client { self.state.ecs_mut().apply_entity_package(entity_package); }, ServerMsg::DeleteEntity(entity) => { - if self.state.read_component_cloned::(self.entity) != Some(entity) { + if self.uid() != Some(entity) { self.state .ecs_mut() .delete_entity_and_clear_from_uid_allocator(entity.0); @@ -1086,6 +1273,9 @@ impl Client { /// Get the player's entity. pub fn entity(&self) -> EcsEntity { self.entity } + /// Get the player's Uid. + pub fn uid(&self) -> Option { self.state.read_component_copied(self.entity) } + /// Get the client state pub fn get_client_state(&self) -> ClientState { self.client_state } @@ -1137,7 +1327,7 @@ impl Client { pub fn is_admin(&self) -> bool { let client_uid = self .state - .read_component_cloned::(self.entity) + .read_component_copied::(self.entity) .expect("Client doesn't have a Uid!!!"); self.player_list @@ -1148,8 +1338,7 @@ impl Client { /// Clean client ECS state fn clean_state(&mut self) { let client_uid = self - .state - .read_component_cloned::(self.entity) + .uid() .map(|u| u.into()) .expect("Client doesn't have a Uid!!!"); @@ -1220,7 +1409,7 @@ impl Client { comp::ChatType::Tell(from, to) => { let from_alias = alias_of_uid(from); let to_alias = alias_of_uid(to); - if Some(from) == self.state.ecs().read_storage::().get(self.entity) { + if Some(*from) == self.uid() { format!("To [{}]: {}", to_alias, message) } else { format!("From [{}]: {}", from_alias, message) diff --git a/common/Cargo.toml b/common/Cargo.toml index 6a7d2e8cc5..92d63c8045 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -30,6 +30,7 @@ notify = "5.0.0-pre.3" indexmap = "1.3.0" sum_type = "0.2.0" authc = { git = "https://gitlab.com/veloren/auth.git", rev = "b943c85e4a38f5ec60cd18c34c73097640162bfe" } +slab = "0.4.2" [dev-dependencies] criterion = "0.3" diff --git a/common/src/cmd.rs b/common/src/cmd.rs index 812af83795..6d8ed3af46 100644 --- a/common/src/cmd.rs +++ b/common/src/cmd.rs @@ -50,7 +50,6 @@ pub enum ChatCommand { Health, Help, JoinFaction, - JoinGroup, Jump, Kill, KillNpcs, @@ -92,7 +91,6 @@ pub static CHAT_COMMANDS: &[ChatCommand] = &[ ChatCommand::Health, ChatCommand::Help, ChatCommand::JoinFaction, - ChatCommand::JoinGroup, ChatCommand::Jump, ChatCommand::Kill, ChatCommand::KillNpcs, @@ -246,11 +244,6 @@ impl ChatCommand { "Join/leave the specified faction", NoAdmin, ), - ChatCommand::JoinGroup => ChatCommandData::new( - vec![Any("group", Optional)], - "Join/leave the specified group", - NoAdmin, - ), ChatCommand::Jump => cmd( vec![ Float("x", 0.0, Required), @@ -383,7 +376,6 @@ impl ChatCommand { ChatCommand::Group => "group", ChatCommand::Health => "health", ChatCommand::JoinFaction => "join_faction", - ChatCommand::JoinGroup => "join_group", ChatCommand::Help => "help", ChatCommand::Jump => "jump", ChatCommand::Kill => "kill", diff --git a/common/src/comp/agent.rs b/common/src/comp/agent.rs index d8bf6336db..41eb83a1cd 100644 --- a/common/src/comp/agent.rs +++ b/common/src/comp/agent.rs @@ -1,10 +1,9 @@ use crate::{path::Chaser, sync::Uid}; -use serde::{Deserialize, Serialize}; -use specs::{Component, Entity as EcsEntity, FlaggedStorage}; +use specs::{Component, Entity as EcsEntity}; use specs_idvs::IdvStorage; use vek::*; -#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum Alignment { /// Wild animals and gentle giants Wild, @@ -52,7 +51,7 @@ impl Alignment { } impl Component for Alignment { - type Storage = FlaggedStorage>; + type Storage = IdvStorage; } #[derive(Clone, Debug, Default)] diff --git a/common/src/comp/chat.rs b/common/src/comp/chat.rs index 589d808ad8..019b1876bd 100644 --- a/common/src/comp/chat.rs +++ b/common/src/comp/chat.rs @@ -1,4 +1,4 @@ -use crate::{msg::ServerMsg, sync::Uid}; +use crate::{comp::group::Group, msg::ServerMsg, sync::Uid}; use serde::{Deserialize, Serialize}; use specs::Component; use specs_idvs::IdvStorage; @@ -15,7 +15,7 @@ pub enum ChatMode { /// Talk to players in your region of the world Region, /// Talk to your current group of players - Group(String), + Group(Group), /// Talk to your faction Faction(String), /// Talk to every player on the server @@ -28,16 +28,16 @@ impl Component for ChatMode { impl ChatMode { /// Create a message from your current chat mode and uuid. - pub fn new_message(&self, from: Uid, message: String) -> ChatMsg { + pub fn new_message(&self, from: Uid, message: String) -> UnresolvedChatMsg { let chat_type = match self { ChatMode::Tell(to) => ChatType::Tell(from, *to), ChatMode::Say => ChatType::Say(from), ChatMode::Region => ChatType::Region(from), - ChatMode::Group(name) => ChatType::Group(from, name.to_string()), - ChatMode::Faction(name) => ChatType::Faction(from, name.to_string()), + ChatMode::Group(group) => ChatType::Group(from, *group), + ChatMode::Faction(faction) => ChatType::Faction(from, faction.clone()), ChatMode::World => ChatType::World(from), }; - ChatMsg { chat_type, message } + UnresolvedChatMsg { chat_type, message } } } @@ -49,7 +49,7 @@ impl Default for ChatMode { /// /// This is a superset of `SpeechBubbleType`, which is a superset of `ChatMode` #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChatType { +pub enum ChatType { /// A player came online Online, /// A player went offline @@ -61,7 +61,7 @@ pub enum ChatType { /// Inform players that someone died Kill, /// Server notifications to a group, such as player join/leave - GroupMeta(String), + GroupMeta(G), /// Server notifications to a faction, such as player join/leave FactionMeta(String), /// One-on-one chat (from, to) @@ -69,7 +69,7 @@ pub enum ChatType { /// Chat with nearby players Say(Uid), /// Group chat - Group(Uid, String), + Group(Uid, G), /// Factional chat Faction(Uid, String), /// Regional chat @@ -86,17 +86,18 @@ pub enum ChatType { Loot, } -impl ChatType { - pub fn chat_msg(self, msg: S) -> ChatMsg +impl ChatType { + pub fn chat_msg(self, msg: S) -> GenericChatMsg where S: Into, { - ChatMsg { + GenericChatMsg { chat_type: self, message: msg.into(), } } - +} +impl ChatType { pub fn server_msg(self, msg: S) -> ServerMsg where S: Into, @@ -106,12 +107,15 @@ impl ChatType { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatMsg { - pub chat_type: ChatType, +pub struct GenericChatMsg { + pub chat_type: ChatType, pub message: String, } -impl ChatMsg { +pub type ChatMsg = GenericChatMsg; +pub type UnresolvedChatMsg = GenericChatMsg; + +impl GenericChatMsg { pub const NPC_DISTANCE: f32 = 100.0; pub const REGION_DISTANCE: f32 = 1000.0; pub const SAY_DISTANCE: f32 = 100.0; @@ -121,6 +125,32 @@ impl ChatMsg { Self { chat_type, message } } + pub fn map_group(self, mut f: impl FnMut(G) -> T) -> GenericChatMsg { + let chat_type = match self.chat_type { + ChatType::Online => ChatType::Online, + ChatType::Offline => ChatType::Offline, + ChatType::CommandInfo => ChatType::CommandInfo, + ChatType::CommandError => ChatType::CommandError, + ChatType::Loot => ChatType::Loot, + ChatType::FactionMeta(a) => ChatType::FactionMeta(a), + ChatType::GroupMeta(g) => ChatType::GroupMeta(f(g)), + ChatType::Kill => ChatType::Kill, + ChatType::Tell(a, b) => ChatType::Tell(a, b), + ChatType::Say(a) => ChatType::Say(a), + ChatType::Group(a, g) => ChatType::Group(a, f(g)), + ChatType::Faction(a, b) => ChatType::Faction(a, b), + ChatType::Region(a) => ChatType::Region(a), + ChatType::World(a) => ChatType::World(a), + ChatType::Npc(a, b) => ChatType::Npc(a, b), + ChatType::Meta => ChatType::Meta, + }; + + GenericChatMsg { + chat_type, + message: self.message, + } + } + pub fn to_bubble(&self) -> Option<(SpeechBubble, Uid)> { let icon = self.icon(); if let ChatType::Npc(from, r) = self.chat_type { @@ -174,19 +204,6 @@ impl ChatMsg { } } -/// Player groups are useful when forming raiding parties and coordinating -/// gameplay. -/// -/// Groups are currently just an associated String (the group's name) -#[derive(Clone, Debug)] -pub struct Group(pub String); -impl Component for Group { - type Storage = IdvStorage; -} -impl From for Group { - fn from(s: String) -> Self { Group(s) } -} - /// Player factions are used to coordinate pvp vs hostile factions or segment /// chat from the world /// diff --git a/common/src/comp/controller.rs b/common/src/comp/controller.rs index 01942d68f1..3c2c6c8828 100644 --- a/common/src/comp/controller.rs +++ b/common/src/comp/controller.rs @@ -18,12 +18,23 @@ pub enum InventoryManip { CraftRecipe(String), } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum GroupManip { + Invite(Uid), + Accept, + Decline, + Leave, + Kick(Uid), + AssignLeader(Uid), +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum ControlEvent { ToggleLantern, Mount(Uid), Unmount, InventoryManip(InventoryManip), + GroupManip(GroupManip), Respawn, } diff --git a/common/src/comp/group.rs b/common/src/comp/group.rs new file mode 100644 index 0000000000..bf11ad7f58 --- /dev/null +++ b/common/src/comp/group.rs @@ -0,0 +1,528 @@ +use crate::{comp::Alignment, sync::Uid}; +use hashbrown::HashMap; +use serde::{Deserialize, Serialize}; +use slab::Slab; +use specs::{Component, FlaggedStorage, Join}; +use specs_idvs::IdvStorage; +use tracing::{error, warn}; + +// Primitive group system +// Shortcomings include: +// - no support for more complex group structures +// - lack of npc group integration +// - relies on careful management of groups to maintain a valid state +// - the possesion rod could probably wreck this +// - clients don't know which pets are theirs (could be easy to solve by +// putting owner uid in Role::Pet) + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Group(u32); + +// TODO: Hack +// Corresponds to Alignment::Enemy +pub const ENEMY: Group = Group(u32::MAX); +// Corresponds to Alignment::Npc | Alignment::Tame +pub const NPC: Group = Group(u32::MAX - 1); + +impl Component for Group { + type Storage = FlaggedStorage>; +} + +pub struct Invite(pub specs::Entity); +impl Component for Invite { + type Storage = IdvStorage; +} + +// Pending invites that an entity currently has sent out +// (invited entity, instant when invite times out) +pub struct PendingInvites(pub Vec<(specs::Entity, std::time::Instant)>); +impl Component for PendingInvites { + type Storage = IdvStorage; +} + +#[derive(Clone, Debug)] +pub struct GroupInfo { + // TODO: what about enemy groups, either the leader will constantly change because they have to + // be loaded or we create a dummy entity or this needs to be optional + pub leader: specs::Entity, + // Number of group members (excluding pets) + pub num_members: u32, + // Name of the group + pub name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Role { + Member, + Pet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ChangeNotification { + // :D + Added(E, Role), + // :( + Removed(E), + NewLeader(E), + // Use to put in a group overwriting existing group + NewGroup { leader: E, members: Vec<(E, Role)> }, + // No longer in a group + NoGroup, +} +// Note: now that we are dipping into uids here consider just using +// ChangeNotification everywhere +// Also note when the same notification is sent to multiple destinations the +// maping might be duplicated effort +impl ChangeNotification { + pub fn try_map(self, f: impl Fn(E) -> Option) -> Option> { + match self { + Self::Added(e, r) => f(e).map(|t| ChangeNotification::Added(t, r)), + Self::Removed(e) => f(e).map(ChangeNotification::Removed), + Self::NewLeader(e) => f(e).map(ChangeNotification::NewLeader), + // Note just discards members that fail map + Self::NewGroup { leader, members } => { + f(leader).map(|leader| ChangeNotification::NewGroup { + leader, + members: members + .into_iter() + .filter_map(|(e, r)| f(e).map(|t| (t, r))) + .collect(), + }) + }, + Self::NoGroup => Some(ChangeNotification::NoGroup), + } + } +} + +type GroupsMut<'a> = specs::WriteStorage<'a, Group>; +type Groups<'a> = specs::ReadStorage<'a, Group>; +type Alignments<'a> = specs::ReadStorage<'a, Alignment>; +type Uids<'a> = specs::ReadStorage<'a, Uid>; + +#[derive(Debug, Default)] +pub struct GroupManager { + groups: Slab, +} + +// Gather list of pets of the group member +// Note: iterating through all entities here could become slow at higher entity +// counts +fn pets( + entity: specs::Entity, + uid: Uid, + alignments: &Alignments, + entities: &specs::Entities, +) -> Vec { + (entities, alignments) + .join() + .filter_map(|(e, a)| { + matches!(a, Alignment::Owned(owner) if *owner == uid && e != entity).then_some(e) + }) + .collect::>() +} + +/// Returns list of current members of a group +pub fn members<'a>( + group: Group, + groups: impl Join + 'a, + entities: &'a specs::Entities, + alignments: &'a Alignments, + uids: &'a Uids, +) -> impl Iterator + 'a { + (entities, groups, alignments, uids) + .join() + .filter_map(move |(e, g, a, u)| { + (*g == group).then(|| { + ( + e, + if matches!(a, Alignment::Owned(owner) if owner != u) { + Role::Pet + } else { + Role::Member + }, + ) + }) + }) +} + +// TODO: optimize add/remove for massive NPC groups +impl GroupManager { + pub fn group_info(&self, group: Group) -> Option<&GroupInfo> { + self.groups.get(group.0 as usize) + } + + fn group_info_mut(&mut self, group: Group) -> Option<&mut GroupInfo> { + self.groups.get_mut(group.0 as usize) + } + + fn create_group(&mut self, leader: specs::Entity, num_members: u32) -> Group { + Group(self.groups.insert(GroupInfo { + leader, + num_members, + name: "Group".into(), + }) as u32) + } + + fn remove_group(&mut self, group: Group) { self.groups.remove(group.0 as usize); } + + // Add someone to a group + // Also used to create new groups + #[allow(clippy::too_many_arguments)] // TODO: Pending review in #587 + pub fn add_group_member( + &mut self, + leader: specs::Entity, + new_member: specs::Entity, + entities: &specs::Entities, + groups: &mut GroupsMut, + alignments: &Alignments, + uids: &Uids, + mut notifier: impl FnMut(specs::Entity, ChangeNotification), + ) { + // Ensure leader is not inviting themselves + if leader == new_member { + warn!("Attempt to form group with leader as the only member (this is disallowed)"); + return; + } + + // Get uid + let new_member_uid = if let Some(uid) = uids.get(new_member) { + *uid + } else { + error!("Failed to retrieve uid for the new group member"); + return; + }; + + // If new member is a member of a different group remove that + if groups + .get(new_member) + .and_then(|g| self.group_info(*g)) + .is_some() + { + self.leave_group( + new_member, + groups, + alignments, + uids, + entities, + &mut notifier, + ) + } + + let group = match groups.get(leader).copied() { + Some(id) + if self + .group_info(id) + .map(|info| info.leader == leader) + .unwrap_or(false) => + { + Some(id) + }, + // Member of an existing group can't be a leader + // If the lead is a member of another group leave that group first + Some(_) => { + self.leave_group(leader, groups, alignments, uids, entities, &mut notifier); + None + }, + None => None, + }; + + let group = if let Some(group) = group { + // Increment group size + // Note: unwrap won't fail since we just retrieved the group successfully above + self.group_info_mut(group).unwrap().num_members += 1; + group + } else { + let new_group = self.create_group(leader, 2); + // Unwrap should not fail since we just found these entities and they should + // still exist Note: if there is an issue replace with a warn + groups.insert(leader, new_group).unwrap(); + // Inform + notifier(leader, ChangeNotification::NewLeader(leader)); + new_group + }; + + let new_pets = pets(new_member, new_member_uid, alignments, entities); + + // Inform + members(group, &*groups, entities, alignments, uids).for_each(|(e, role)| match role { + Role::Member => { + notifier(e, ChangeNotification::Added(new_member, Role::Member)); + notifier(new_member, ChangeNotification::Added(e, Role::Member)); + + new_pets.iter().for_each(|p| { + notifier(e, ChangeNotification::Added(*p, Role::Pet)); + }) + }, + Role::Pet => { + notifier(new_member, ChangeNotification::Added(e, Role::Pet)); + }, + }); + notifier(new_member, ChangeNotification::NewLeader(leader)); + + // Add group id for new member and pets + // Unwrap should not fail since we just found these entities and they should + // still exist + // Note: if there is an issue replace with a warn + let _ = groups.insert(new_member, group).unwrap(); + new_pets.iter().for_each(|e| { + let _ = groups.insert(*e, group).unwrap(); + }); + } + + #[allow(clippy::too_many_arguments)] // TODO: Pending review in #587 + pub fn new_pet( + &mut self, + pet: specs::Entity, + owner: specs::Entity, + groups: &mut GroupsMut, + entities: &specs::Entities, + alignments: &Alignments, + uids: &Uids, + notifier: &mut impl FnMut(specs::Entity, ChangeNotification), + ) { + let group = match groups.get(owner).copied() { + Some(group) => group, + None => { + let new_group = self.create_group(owner, 1); + groups.insert(owner, new_group).unwrap(); + // Inform + notifier(owner, ChangeNotification::NewLeader(owner)); + new_group + }, + }; + + // Inform + members(group, &*groups, entities, alignments, uids).for_each(|(e, role)| match role { + Role::Member => { + notifier(e, ChangeNotification::Added(pet, Role::Pet)); + }, + Role::Pet => {}, + }); + + // Add + groups.insert(pet, group).unwrap(); + } + + pub fn leave_group( + &mut self, + member: specs::Entity, + groups: &mut GroupsMut, + alignments: &Alignments, + uids: &Uids, + entities: &specs::Entities, + notifier: &mut impl FnMut(specs::Entity, ChangeNotification), + ) { + // Pets can't leave + if matches!(alignments.get(member), Some(Alignment::Owned(uid)) if uids.get(member).map_or(true, |u| u != uid)) + { + return; + } + self.remove_from_group(member, groups, alignments, uids, entities, notifier, false); + + // Set NPC back to their group + if let Some(alignment) = alignments.get(member) { + match alignment { + Alignment::Npc => { + let _ = groups.insert(member, NPC); + }, + Alignment::Enemy => { + let _ = groups.insert(member, ENEMY); + }, + _ => {}, + } + } + } + + pub fn entity_deleted( + &mut self, + member: specs::Entity, + groups: &mut GroupsMut, + alignments: &Alignments, + uids: &Uids, + entities: &specs::Entities, + notifier: &mut impl FnMut(specs::Entity, ChangeNotification), + ) { + self.remove_from_group(member, groups, alignments, uids, entities, notifier, true); + } + + // Remove someone from a group if they are in one + // Don't need to check if they are in a group before calling this + // Also removes pets (ie call this if the pet no longer exists) + #[allow(clippy::too_many_arguments)] // TODO: Pending review in #587 + fn remove_from_group( + &mut self, + member: specs::Entity, + groups: &mut GroupsMut, + alignments: &Alignments, + uids: &Uids, + entities: &specs::Entities, + notifier: &mut impl FnMut(specs::Entity, ChangeNotification), + to_be_deleted: bool, + ) { + let group = match groups.get(member) { + Some(group) => *group, + None => return, + }; + + // If leaving entity was the leader disband the group + if self + .group_info(group) + .map(|info| info.leader == member) + .unwrap_or(false) + { + // Remove group + self.remove_group(group); + + (entities, uids, &*groups, alignments.maybe()) + .join() + .filter(|(e, _, g, _)| **g == group && !(to_be_deleted && *e == member)) + .fold( + HashMap::, Vec)>::new(), + |mut acc, (e, uid, _, alignment)| { + if let Some(owner) = alignment.and_then(|a| match a { + Alignment::Owned(owner) if uid != owner => Some(owner), + _ => None, + }) { + // A pet + // Assumes owner will be in the group + acc.entry(*owner).or_default().1.push(e); + } else { + // Not a pet + acc.entry(*uid).or_default().0 = Some(e); + } + + acc + }, + ) + .into_iter() + .map(|(_, v)| v) + .for_each(|(owner, pets)| { + if let Some(owner) = owner { + if !pets.is_empty() { + let mut members = + pets.iter().map(|e| (*e, Role::Pet)).collect::>(); + members.push((owner, Role::Member)); + + // New group + let new_group = self.create_group(owner, 1); + for (member, _) in &members { + groups.insert(*member, new_group).unwrap(); + } + + notifier(owner, ChangeNotification::NewGroup { + leader: owner, + members, + }); + } else { + // If no pets just remove group + groups.remove(owner); + notifier(owner, ChangeNotification::NoGroup) + } + } else { + // Owner not found, potentially the were removed from the world + pets.into_iter().for_each(|pet| { + groups.remove(pet); + }); + } + }); + } else { + // Not leader + let leaving_member_uid = if let Some(uid) = uids.get(member) { + *uid + } else { + error!("Failed to retrieve uid for the leaving member"); + return; + }; + + let leaving_pets = pets(member, leaving_member_uid, alignments, entities); + + // If pets and not about to be deleted form new group + if !leaving_pets.is_empty() && !to_be_deleted { + let new_group = self.create_group(member, 1); + + notifier(member, ChangeNotification::NewGroup { + leader: member, + members: leaving_pets + .iter() + .map(|p| (*p, Role::Pet)) + .chain(std::iter::once((member, Role::Member))) + .collect(), + }); + + let _ = groups.insert(member, new_group).unwrap(); + leaving_pets.iter().for_each(|&e| { + let _ = groups.insert(e, new_group).unwrap(); + }); + } else { + let _ = groups.remove(member); + notifier(member, ChangeNotification::NoGroup); + leaving_pets.iter().for_each(|&e| { + let _ = groups.remove(e); + }); + } + + if let Some(info) = self.group_info_mut(group) { + // If not pet, decrement number of members + if !matches!(alignments.get(member), Some(Alignment::Owned(owner)) if uids.get(member).map_or(true, |uid| uid != owner)) + { + if info.num_members > 0 { + info.num_members -= 1; + } else { + error!("Group with invalid number of members") + } + } + + let mut remaining_count = 0; // includes pets + // Inform remaining members + members(group, &*groups, entities, alignments, uids).for_each(|(e, role)| { + remaining_count += 1; + match role { + Role::Member => { + notifier(e, ChangeNotification::Removed(member)); + leaving_pets.iter().for_each(|p| { + notifier(e, ChangeNotification::Removed(*p)); + }) + }, + Role::Pet => {}, + } + }); + // If leader is the last one left then disband the group + // Assumes last member is the leader + if remaining_count == 1 { + let leader = info.leader; + self.remove_group(group); + groups.remove(leader); + notifier(leader, ChangeNotification::NoGroup); + } else if remaining_count == 0 { + error!("Somehow group has no members") + } + } + } + } + + // Assign new group leader + // Does nothing if new leader is not part of a group + pub fn assign_leader( + &mut self, + new_leader: specs::Entity, + groups: &Groups, + entities: &specs::Entities, + alignments: &Alignments, + uids: &Uids, + mut notifier: impl FnMut(specs::Entity, ChangeNotification), + ) { + let group = match groups.get(new_leader) { + Some(group) => *group, + None => return, + }; + + // Set new leader + self.groups[group.0 as usize].leader = new_leader; + + // Point to new leader + members(group, &*groups, entities, alignments, uids).for_each(|(e, role)| match role { + Role::Member => notifier(e, ChangeNotification::NewLeader(new_leader)), + Role::Pet => {}, + }); + } +} diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index b99b575450..ccef80dc8b 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -7,6 +7,7 @@ mod chat; mod controller; mod damage; mod energy; +pub mod group; mod inputs; mod inventory; mod last; @@ -28,13 +29,16 @@ pub use body::{ humanoid, object, quadruped_low, quadruped_medium, quadruped_small, AllBodies, Body, BodyData, }; pub use character_state::{Attacking, CharacterState, StateUpdate}; -pub use chat::{ChatMode, ChatMsg, ChatType, Faction, Group, SpeechBubble, SpeechBubbleType}; +pub use chat::{ + ChatMode, ChatMsg, ChatType, Faction, SpeechBubble, SpeechBubbleType, UnresolvedChatMsg, +}; pub use controller::{ - Climb, ControlAction, ControlEvent, Controller, ControllerInputs, Input, InventoryManip, - MountState, Mounting, + Climb, ControlAction, ControlEvent, Controller, ControllerInputs, GroupManip, Input, + InventoryManip, MountState, Mounting, }; pub use damage::{Damage, DamageSource}; pub use energy::{Energy, EnergySource}; +pub use group::Group; pub use inputs::CanBuild; pub use inventory::{ item, diff --git a/common/src/event.rs b/common/src/event.rs index 7bcb6683db..943757a2a7 100644 --- a/common/src/event.rs +++ b/common/src/event.rs @@ -25,6 +25,7 @@ pub enum ServerEvent { pos: Vec3, power: f32, owner: Option, + friendly_damage: bool, }, Damage { uid: Uid, @@ -35,6 +36,7 @@ pub enum ServerEvent { cause: comp::HealthSource, }, InventoryManip(EcsEntity, comp::InventoryManip), + GroupManip(EcsEntity, comp::GroupManip), Respawn(EcsEntity), Shoot { entity: EcsEntity, @@ -80,7 +82,7 @@ pub enum ServerEvent { ChunkRequest(EcsEntity, Vec2), ChatCmd(EcsEntity, String), /// Send a chat message to the player from an npc or other player - Chat(comp::ChatMsg), + Chat(comp::UnresolvedChatMsg), } pub struct EventBus { diff --git a/common/src/msg/ecs_packet.rs b/common/src/msg/ecs_packet.rs index 1df92d9382..726d67321a 100644 --- a/common/src/msg/ecs_packet.rs +++ b/common/src/msg/ecs_packet.rs @@ -17,7 +17,7 @@ sum_type! { LightEmitter(comp::LightEmitter), Item(comp::Item), Scale(comp::Scale), - Alignment(comp::Alignment), + Group(comp::Group), MountState(comp::MountState), Mounting(comp::Mounting), Mass(comp::Mass), @@ -44,7 +44,7 @@ sum_type! { LightEmitter(PhantomData), Item(PhantomData), Scale(PhantomData), - Alignment(PhantomData), + Group(PhantomData), MountState(PhantomData), Mounting(PhantomData), Mass(PhantomData), @@ -71,7 +71,7 @@ impl sync::CompPacket for EcsCompPacket { EcsCompPacket::LightEmitter(comp) => sync::handle_insert(comp, entity, world), EcsCompPacket::Item(comp) => sync::handle_insert(comp, entity, world), EcsCompPacket::Scale(comp) => sync::handle_insert(comp, entity, world), - EcsCompPacket::Alignment(comp) => sync::handle_insert(comp, entity, world), + EcsCompPacket::Group(comp) => sync::handle_insert(comp, entity, world), EcsCompPacket::MountState(comp) => sync::handle_insert(comp, entity, world), EcsCompPacket::Mounting(comp) => sync::handle_insert(comp, entity, world), EcsCompPacket::Mass(comp) => sync::handle_insert(comp, entity, world), @@ -96,7 +96,7 @@ impl sync::CompPacket for EcsCompPacket { EcsCompPacket::LightEmitter(comp) => sync::handle_modify(comp, entity, world), EcsCompPacket::Item(comp) => sync::handle_modify(comp, entity, world), EcsCompPacket::Scale(comp) => sync::handle_modify(comp, entity, world), - EcsCompPacket::Alignment(comp) => sync::handle_modify(comp, entity, world), + EcsCompPacket::Group(comp) => sync::handle_modify(comp, entity, world), EcsCompPacket::MountState(comp) => sync::handle_modify(comp, entity, world), EcsCompPacket::Mounting(comp) => sync::handle_modify(comp, entity, world), EcsCompPacket::Mass(comp) => sync::handle_modify(comp, entity, world), @@ -123,7 +123,7 @@ impl sync::CompPacket for EcsCompPacket { }, EcsCompPhantom::Item(_) => sync::handle_remove::(entity, world), EcsCompPhantom::Scale(_) => sync::handle_remove::(entity, world), - EcsCompPhantom::Alignment(_) => sync::handle_remove::(entity, world), + EcsCompPhantom::Group(_) => sync::handle_remove::(entity, world), EcsCompPhantom::MountState(_) => sync::handle_remove::(entity, world), EcsCompPhantom::Mounting(_) => sync::handle_remove::(entity, world), EcsCompPhantom::Mass(_) => sync::handle_remove::(entity, world), diff --git a/common/src/msg/mod.rs b/common/src/msg/mod.rs index f7d8c3d118..4f2f1585b1 100644 --- a/common/src/msg/mod.rs +++ b/common/src/msg/mod.rs @@ -7,7 +7,7 @@ pub use self::{ client::ClientMsg, ecs_packet::EcsCompPacket, server::{ - CharacterInfo, Notification, PlayerInfo, PlayerListUpdate, RegisterError, + CharacterInfo, InviteAnswer, Notification, PlayerInfo, PlayerListUpdate, RegisterError, RequestStateError, ServerInfo, ServerMsg, }, }; diff --git a/common/src/msg/server.rs b/common/src/msg/server.rs index a83dc6216e..156c18fd61 100644 --- a/common/src/msg/server.rs +++ b/common/src/msg/server.rs @@ -47,6 +47,13 @@ pub struct CharacterInfo { pub level: u32, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum InviteAnswer { + Accepted, + Declined, + TimedOut, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Notification { WaypointSaved, @@ -59,6 +66,7 @@ pub enum ServerMsg { entity_package: sync::EntityPackage, server_info: ServerInfo, time_of_day: state::TimeOfDay, + max_group_size: u32, world_map: (Vec2, Vec), recipe_book: RecipeBook, }, @@ -69,6 +77,22 @@ pub enum ServerMsg { /// An error occured while creating or deleting a character CharacterActionError(String), PlayerListUpdate(PlayerListUpdate), + GroupUpdate(comp::group::ChangeNotification), + // Indicate to the client that they are invited to join a group + GroupInvite { + inviter: sync::Uid, + timeout: std::time::Duration, + }, + // Indicate to the client that their sent invite was not invalid and is currently pending + InvitePending(sync::Uid), + // Note: this could potentially include all the failure cases such as inviting yourself in + // which case the `InvitePending` message could be removed and the client could consider their + // invite pending until they receive this message + // Indicate to the client the result of their invite + InviteComplete { + target: sync::Uid, + answer: InviteAnswer, + }, StateAnswer(Result), /// Trigger cleanup for when the client goes back to the `Registered` state /// from an ingame state diff --git a/common/src/state.rs b/common/src/state.rs index f232d07163..3d003b7a4f 100644 --- a/common/src/state.rs +++ b/common/src/state.rs @@ -123,7 +123,7 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); - ecs.register::(); + ecs.register::(); // Register components send from clients -> server ecs.register::(); @@ -146,6 +146,7 @@ impl State { ecs.register::>(); ecs.register::>(); ecs.register::>(); + ecs.register::(); ecs.register::(); ecs.register::(); ecs.register::(); @@ -156,8 +157,9 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); - ecs.register::(); ecs.register::(); + ecs.register::(); + ecs.register::(); // Register synced resources used by the ECS. ecs.insert(TimeOfDay(0.0)); @@ -168,9 +170,10 @@ impl State { ecs.insert(TerrainGrid::new().unwrap()); ecs.insert(BlockChange::default()); ecs.insert(TerrainChanges::default()); + ecs.insert(EventBus::::default()); // TODO: only register on the server ecs.insert(EventBus::::default()); - ecs.insert(EventBus::::default()); + ecs.insert(comp::group::GroupManager::default()); ecs.insert(RegionMap::new()); ecs @@ -196,8 +199,8 @@ impl State { } /// Read a component attributed to a particular entity. - pub fn read_component_cloned(&self, entity: EcsEntity) -> Option { - self.ecs.read_storage().get(entity).cloned() + pub fn read_component_copied(&self, entity: EcsEntity) -> Option { + self.ecs.read_storage().get(entity).copied() } /// Get a read-only reference to the storage of a particular component type. diff --git a/common/src/sys/agent.rs b/common/src/sys/agent.rs index 36b8afd4e9..e2cb88b750 100644 --- a/common/src/sys/agent.rs +++ b/common/src/sys/agent.rs @@ -2,9 +2,12 @@ use crate::{ comp::{ self, agent::Activity, + group, + group::Invite, item::{tool::ToolKind, ItemKind}, - Agent, Alignment, Body, CharacterState, ChatMsg, ControlAction, Controller, Loadout, - MountState, Ori, PhysicsState, Pos, Scale, Stats, Vel, + Agent, Alignment, Body, CharacterState, ControlAction, ControlEvent, Controller, + GroupManip, Loadout, MountState, Ori, PhysicsState, Pos, Scale, Stats, UnresolvedChatMsg, + Vel, }, event::{EventBus, ServerEvent}, path::{Chaser, TraversalConfig}, @@ -29,6 +32,7 @@ impl<'a> System<'a> for Sys { Read<'a, UidAllocator>, Read<'a, Time>, Read<'a, DeltaTime>, + Read<'a, group::GroupManager>, Write<'a, EventBus>, Entities<'a>, ReadStorage<'a, Pos>, @@ -40,12 +44,14 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, CharacterState>, ReadStorage<'a, PhysicsState>, ReadStorage<'a, Uid>, + ReadStorage<'a, group::Group>, ReadExpect<'a, TerrainGrid>, ReadStorage<'a, Alignment>, ReadStorage<'a, Body>, WriteStorage<'a, Agent>, WriteStorage<'a, Controller>, ReadStorage<'a, MountState>, + ReadStorage<'a, Invite>, ); #[allow(clippy::or_fun_call)] // TODO: Pending review in #587 @@ -55,6 +61,7 @@ impl<'a> System<'a> for Sys { uid_allocator, time, dt, + group_manager, event_bus, entities, positions, @@ -66,12 +73,14 @@ impl<'a> System<'a> for Sys { character_states, physics_states, uids, + groups, terrain, alignments, bodies, mut agents, mut controllers, mount_states, + invites, ): Self::SystemData, ) { for ( @@ -88,6 +97,7 @@ impl<'a> System<'a> for Sys { agent, controller, mount_state, + group, ) in ( &entities, &positions, @@ -102,9 +112,23 @@ impl<'a> System<'a> for Sys { &mut agents, &mut controllers, mount_states.maybe(), + groups.maybe(), ) .join() { + // Hack, replace with better system when groups are more sophisticated + // Override alignment if in a group unless entity is owned already + let alignment = if !matches!(alignment, Some(Alignment::Owned(_))) { + group + .and_then(|g| group_manager.group_info(*g)) + .and_then(|info| uids.get(info.leader)) + .copied() + .map(Alignment::Owned) + .or(alignment.copied()) + } else { + alignment.copied() + }; + // Skip mounted entities if mount_state .map(|ms| *ms != MountState::Unmounted) @@ -117,7 +141,7 @@ impl<'a> System<'a> for Sys { let mut inputs = &mut controller.inputs; - // Default to looking in orientation direction + // Default to looking in orientation direction (can be overriden below) inputs.look_dir = ori.0; const AVG_FOLLOW_DIST: f32 = 6.0; @@ -148,11 +172,9 @@ impl<'a> System<'a> for Sys { thread_rng().gen::() - 0.5, ) * 0.1 - *bearing * 0.003 - - if let Some(patrol_origin) = agent.patrol_origin { - Vec2::::from(pos.0 - patrol_origin) * 0.0002 - } else { - Vec2::zero() - }; + - agent.patrol_origin.map_or(Vec2::zero(), |patrol_origin| { + (pos.0 - patrol_origin).xy() * 0.0002 + }); // Stop if we're too close to a wall *bearing *= 0.1 @@ -169,8 +191,7 @@ impl<'a> System<'a> for Sys { .until(|block| block.is_solid()) .cast() .1 - .map(|b| b.is_none()) - .unwrap_or(true) + .map_or(true, |b| b.is_none()) { 0.9 } else { @@ -269,8 +290,7 @@ impl<'a> System<'a> for Sys { // Don't attack entities we are passive towards // TODO: This is here, it's a bit of a hack if let Some(alignment) = alignment { - if (*alignment).passive_towards(tgt_alignment) || tgt_stats.is_dead - { + if alignment.passive_towards(tgt_alignment) || tgt_stats.is_dead { do_idle = true; break 'activity; } @@ -418,8 +438,9 @@ impl<'a> System<'a> for Sys { if stats.get(attacker).map_or(false, |a| !a.is_dead) { if agent.can_speak { let msg = "npc.speech.villager_under_attack".to_string(); - event_bus - .emit_now(ServerEvent::Chat(ChatMsg::npc(*uid, msg))); + event_bus.emit_now(ServerEvent::Chat( + UnresolvedChatMsg::npc(*uid, msg), + )); } agent.activity = Activity::Attack { @@ -437,7 +458,7 @@ impl<'a> System<'a> for Sys { } // Follow owner if we're too far, or if they're under attack - if let Some(Alignment::Owned(owner)) = alignment.copied() { + if let Some(Alignment::Owned(owner)) = alignment { (|| { let owner = uid_allocator.retrieve_entity_internal(owner.id())?; @@ -477,5 +498,23 @@ impl<'a> System<'a> for Sys { debug_assert!(inputs.move_dir.map(|e| !e.is_nan()).reduce_and()); debug_assert!(inputs.look_dir.map(|e| !e.is_nan()).reduce_and()); } + + // Proccess group invites + for (_invite, alignment, agent, controller) in + (&invites, &alignments, &mut agents, &mut controllers).join() + { + let accept = matches!(alignment, Alignment::Npc); + if accept { + // Clear agent comp + *agent = Agent::default(); + controller + .events + .push(ControlEvent::GroupManip(GroupManip::Accept)); + } else { + controller + .events + .push(ControlEvent::GroupManip(GroupManip::Decline)); + } + } } } diff --git a/common/src/sys/combat.rs b/common/src/sys/combat.rs index d585171b0d..e8d83e9847 100644 --- a/common/src/sys/combat.rs +++ b/common/src/sys/combat.rs @@ -1,7 +1,7 @@ use crate::{ comp::{ - Alignment, Attacking, Body, CharacterState, Damage, DamageSource, HealthChange, - HealthSource, Loadout, Ori, Pos, Scale, Stats, + group, Attacking, Body, CharacterState, Damage, DamageSource, HealthChange, HealthSource, + Loadout, Ori, Pos, Scale, Stats, }, event::{EventBus, LocalEvent, ServerEvent}, sync::Uid, @@ -26,10 +26,10 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, Pos>, ReadStorage<'a, Ori>, ReadStorage<'a, Scale>, - ReadStorage<'a, Alignment>, ReadStorage<'a, Body>, ReadStorage<'a, Stats>, ReadStorage<'a, Loadout>, + ReadStorage<'a, group::Group>, WriteStorage<'a, Attacking>, WriteStorage<'a, CharacterState>, ); @@ -44,10 +44,10 @@ impl<'a> System<'a> for Sys { positions, orientations, scales, - alignments, bodies, stats, loadouts, + groups, mut attacking_storage, character_states, ): Self::SystemData, @@ -71,23 +71,12 @@ impl<'a> System<'a> for Sys { attack.applied = true; // Go through all other entities - for ( - b, - uid_b, - pos_b, - ori_b, - scale_b_maybe, - alignment_b_maybe, - character_b, - stats_b, - body_b, - ) in ( + for (b, uid_b, pos_b, ori_b, scale_b_maybe, character_b, stats_b, body_b) in ( &entities, &uids, &positions, &orientations, scales.maybe(), - alignments.maybe(), character_states.maybe(), &stats, &bodies, @@ -111,6 +100,17 @@ impl<'a> System<'a> for Sys { && pos.0.distance_squared(pos_b.0) < (rad_b + scale * attack.range).powi(2) && ori2.angle_between(pos_b2 - pos2) < attack.max_angle + (rad_b / pos2.distance(pos_b2)).atan() { + // See if entities are in the same group + let same_group = groups + .get(entity) + .map(|group_a| Some(group_a) == groups.get(b)) + .unwrap_or(false); + // Don't heal if outside group + // Don't damage in the same group + if same_group != (attack.base_healthchange > 0) { + continue; + } + // Weapon gives base damage let source = if attack.base_healthchange > 0 { DamageSource::Healing @@ -121,28 +121,6 @@ impl<'a> System<'a> for Sys { healthchange: attack.base_healthchange as f32, source, }; - let mut knockback = attack.knockback; - - // TODO: remove this, either it will remain unused or be used as a temporary - // gameplay balance - //// NPCs do less damage - //if agent_maybe.is_some() { - // healthchange = (healthchange / 1.5).min(-1.0); - //} - - // TODO: remove this when there is a better way to deal with alignment - // Don't heal NPCs - if (damage.healthchange > 0.0 && alignment_b_maybe - .map(|a| !a.is_friendly_to_players()) - .unwrap_or(true)) - // Don't hurt pets - || (damage.healthchange < 0.0 && alignment_b_maybe - .map(|b| Alignment::Owned(*uid).passive_towards(*b)) - .unwrap_or(false)) - { - damage.healthchange = 0.0; - knockback = 0.0; - } let block = character_b.map(|c_b| c_b.is_block()).unwrap_or(false) && ori_b.0.angle_between(pos.0 - pos_b.0) < BLOCK_ANGLE.to_radians() / 2.0; @@ -160,10 +138,10 @@ impl<'a> System<'a> for Sys { }, }); } - if knockback != 0.0 { + if attack.knockback != 0.0 { local_emitter.emit(LocalEvent::ApplyForce { entity: b, - force: knockback + force: attack.knockback * *Dir::slerp(ori.0, Dir::new(Vec3::new(0.0, 0.0, 1.0)), 0.5), }); } diff --git a/common/src/sys/controller.rs b/common/src/sys/controller.rs index b615d6cdb1..055f7e8dd4 100644 --- a/common/src/sys/controller.rs +++ b/common/src/sys/controller.rs @@ -96,6 +96,9 @@ impl<'a> System<'a> for Sys { } server_emitter.emit(ServerEvent::InventoryManip(entity, manip)) }, + ControlEvent::GroupManip(manip) => { + server_emitter.emit(ServerEvent::GroupManip(entity, manip)) + }, ControlEvent::Respawn => server_emitter.emit(ServerEvent::Respawn(entity)), } } diff --git a/common/src/sys/phys.rs b/common/src/sys/phys.rs index c065a9621f..ffec0df1b4 100644 --- a/common/src/sys/phys.rs +++ b/common/src/sys/phys.rs @@ -1,12 +1,17 @@ use crate::{ - comp::{Collider, Gravity, Mass, Mounting, Ori, PhysicsState, Pos, Scale, Sticky, Vel}, + comp::{ + Collider, Gravity, Group, Mass, Mounting, Ori, PhysicsState, Pos, Projectile, Scale, + Sticky, Vel, + }, event::{EventBus, ServerEvent}, state::DeltaTime, - sync::Uid, + sync::{Uid, UidAllocator}, terrain::{Block, BlockKind, TerrainGrid}, vol::ReadVol, }; -use specs::{Entities, Join, Read, ReadExpect, ReadStorage, System, WriteStorage}; +use specs::{ + saveload::MarkerAllocator, Entities, Join, Read, ReadExpect, ReadStorage, System, WriteStorage, +}; use vek::*; pub const GRAVITY: f32 = 9.81 * 5.0; @@ -44,6 +49,7 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, Uid>, ReadExpect<'a, TerrainGrid>, Read<'a, DeltaTime>, + Read<'a, UidAllocator>, Read<'a, EventBus>, ReadStorage<'a, Scale>, ReadStorage<'a, Sticky>, @@ -55,6 +61,8 @@ impl<'a> System<'a> for Sys { WriteStorage<'a, Vel>, WriteStorage<'a, Ori>, ReadStorage<'a, Mounting>, + ReadStorage<'a, Group>, + ReadStorage<'a, Projectile>, ); #[allow(clippy::or_fun_call)] // TODO: Pending review in #587 @@ -66,6 +74,7 @@ impl<'a> System<'a> for Sys { uids, terrain, dt, + uid_allocator, event_bus, scales, stickies, @@ -77,6 +86,8 @@ impl<'a> System<'a> for Sys { mut velocities, mut orientations, mountings, + groups, + projectiles, ): Self::SystemData, ) { let mut event_emitter = event_bus.emitter(); @@ -432,7 +443,7 @@ impl<'a> System<'a> for Sys { } // Apply pushback - for (pos, scale, mass, vel, _, _, _, physics) in ( + for (pos, scale, mass, vel, _, _, _, physics, projectile) in ( &positions, scales.maybe(), masses.maybe(), @@ -441,9 +452,12 @@ impl<'a> System<'a> for Sys { !&mountings, stickies.maybe(), &mut physics_states, + // TODO: if we need to avoid collisions for other things consider moving whether it + // should interact into the collider component or into a separate component + projectiles.maybe(), ) .join() - .filter(|(_, _, _, _, _, _, sticky, physics)| { + .filter(|(_, _, _, _, _, _, sticky, physics, _)| { sticky.is_none() || (physics.on_wall.is_none() && !physics.on_ground) }) { @@ -452,16 +466,27 @@ impl<'a> System<'a> for Sys { let scale = scale.map(|s| s.0).unwrap_or(1.0); let mass = mass.map(|m| m.0).unwrap_or(scale); - for (other, pos_other, scale_other, mass_other, _, _) in ( + // Group to ignore collisions with + let ignore_group = projectile + .and_then(|p| p.owner) + .and_then(|uid| uid_allocator.retrieve_entity_internal(uid.into())) + .and_then(|e| groups.get(e)); + + for (other, pos_other, scale_other, mass_other, _, _, group) in ( &uids, &positions, scales.maybe(), masses.maybe(), &colliders, !&mountings, + groups.maybe(), ) .join() { + if ignore_group.is_some() && ignore_group == group { + continue; + } + let scale_other = scale_other.map(|s| s.0).unwrap_or(1.0); let mass_other = mass_other.map(|m| m.0).unwrap_or(scale_other); diff --git a/common/src/sys/projectile.rs b/common/src/sys/projectile.rs index ad2e4a1023..20f96fc311 100644 --- a/common/src/sys/projectile.rs +++ b/common/src/sys/projectile.rs @@ -1,7 +1,7 @@ use crate::{ comp::{ - projectile, Alignment, Damage, DamageSource, Energy, EnergySource, HealthChange, - HealthSource, Loadout, Ori, PhysicsState, Pos, Projectile, Vel, + projectile, Damage, DamageSource, Energy, EnergySource, HealthChange, HealthSource, + Loadout, Ori, PhysicsState, Pos, Projectile, Vel, }, event::{EventBus, LocalEvent, ServerEvent}, state::DeltaTime, @@ -28,7 +28,6 @@ impl<'a> System<'a> for Sys { WriteStorage<'a, Ori>, WriteStorage<'a, Projectile>, WriteStorage<'a, Energy>, - ReadStorage<'a, Alignment>, ReadStorage<'a, Loadout>, ); @@ -46,7 +45,6 @@ impl<'a> System<'a> for Sys { mut orientations, mut projectiles, mut energies, - alignments, loadouts, ): Self::SystemData, ) { @@ -72,6 +70,7 @@ impl<'a> System<'a> for Sys { pos: pos.0, power, owner: projectile.owner, + friendly_damage: false, }) }, projectile::Effect::Vanish => server_emitter.emit(ServerEvent::Destroy { @@ -92,23 +91,13 @@ impl<'a> System<'a> for Sys { healthchange: healthchange as f32, source: DamageSource::Projectile, }; - if let Some(entity) = - uid_allocator.retrieve_entity_internal(other.into()) - { - if let Some(loadout) = loadouts.get(entity) { - damage.modify_damage(false, loadout); - } + + let other_entity = uid_allocator.retrieve_entity_internal(other.into()); + if let Some(loadout) = other_entity.and_then(|e| loadouts.get(e)) { + damage.modify_damage(false, loadout); } - // Hacky: remove this when groups get implemented - let passive = uid_allocator - .retrieve_entity_internal(other.into()) - .and_then(|other| { - alignments - .get(other) - .map(|a| Alignment::Owned(owner_uid).passive_towards(*a)) - }) - .unwrap_or(false); - if other != projectile.owner.unwrap() && !passive { + + if other != owner_uid { server_emitter.emit(ServerEvent::Damage { uid: other, change: HealthChange { @@ -143,6 +132,7 @@ impl<'a> System<'a> for Sys { pos: pos.0, power, owner: projectile.owner, + friendly_damage: false, }) }, projectile::Effect::Vanish => server_emitter.emit(ServerEvent::Destroy { diff --git a/server/src/cmd.rs b/server/src/cmd.rs index d65c62ab29..b580cfbd98 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -2,7 +2,7 @@ //! To implement a new command, add an instance of `ChatCommand` to //! `CHAT_COMMANDS` and provide a handler function. -use crate::{Server, StateExt}; +use crate::{client::Client, Server, StateExt}; use chrono::{NaiveTime, Timelike}; use common::{ assets, @@ -77,7 +77,6 @@ fn get_handler(cmd: &ChatCommand) -> CommandHandler { ChatCommand::Health => handle_health, ChatCommand::Help => handle_help, ChatCommand::JoinFaction => handle_join_faction, - ChatCommand::JoinGroup => handle_join_group, ChatCommand::Jump => handle_jump, ChatCommand::Kill => handle_kill, ChatCommand::KillNpcs => handle_kill_npcs, @@ -227,7 +226,7 @@ fn handle_jump( action: &ChatCommand, ) { if let Ok((x, y, z)) = scan_fmt!(&args, &action.arg_fmt(), f32, f32, f32) { - match server.state.read_component_cloned::(target) { + match server.state.read_component_copied::(target) { Some(current_pos) => { server .state @@ -252,7 +251,7 @@ fn handle_goto( if let Ok((x, y, z)) = scan_fmt!(&args, &action.arg_fmt(), f32, f32, f32) { if server .state - .read_component_cloned::(target) + .read_component_copied::(target) .is_some() { server @@ -463,9 +462,9 @@ fn handle_tp( ); return; }; - if let Some(_pos) = server.state.read_component_cloned::(target) { + if let Some(_pos) = server.state.read_component_copied::(target) { if let Some(player) = opt_player { - if let Some(pos) = server.state.read_component_cloned::(player) { + if let Some(pos) = server.state.read_component_copied::(player) { server.state.write_component(target, pos); server.state.write_component(target, comp::ForceUpdate); } else { @@ -510,7 +509,7 @@ fn handle_spawn( (Some(opt_align), Some(npc::NpcBody(id, mut body)), opt_amount, opt_ai) => { let uid = server .state - .read_component_cloned(target) + .read_component_copied(target) .expect("Expected player to have a UID"); if let Some(alignment) = parse_alignment(uid, &opt_align) { let amount = opt_amount @@ -521,7 +520,7 @@ fn handle_spawn( let ai = opt_ai.unwrap_or_else(|| "true".to_string()); - match server.state.read_component_cloned::(target) { + match server.state.read_component_copied::(target) { Some(pos) => { let agent = if let comp::Alignment::Owned(_) | comp::Alignment::Npc = alignment { @@ -557,6 +556,43 @@ fn handle_spawn( let new_entity = entity_base.build(); + // Add to group system if a pet + if matches!(alignment, comp::Alignment::Owned { .. }) { + let state = server.state(); + let mut clients = state.ecs().write_storage::(); + let uids = state.ecs().read_storage::(); + let mut group_manager = + state.ecs().write_resource::(); + group_manager.new_pet( + new_entity, + target, + &mut state.ecs().write_storage(), + &state.ecs().entities(), + &state.ecs().read_storage(), + &uids, + &mut |entity, group_change| { + clients + .get_mut(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| c.notify(ServerMsg::GroupUpdate(g))); + }, + ); + } else if let Some(group) = match alignment { + comp::Alignment::Wild => None, + comp::Alignment::Enemy => Some(comp::group::ENEMY), + comp::Alignment::Npc | comp::Alignment::Tame => { + Some(comp::group::NPC) + }, + comp::Alignment::Owned(_) => unreachable!(), + } { + let _ = + server.state.ecs().write_storage().insert(new_entity, group); + } + if let Some(uid) = server.state.ecs().uid_from_entity(new_entity) { server.notify_client( client, @@ -594,7 +630,7 @@ fn handle_spawn_training_dummy( _args: String, _action: &ChatCommand, ) { - match server.state.read_component_cloned::(target) { + match server.state.read_component_copied::(target) { Some(pos) => { let vel = Vec3::new( rand::thread_rng().gen_range(-2.0, 3.0), @@ -961,13 +997,14 @@ fn handle_explosion( let ecs = server.state.ecs(); - match server.state.read_component_cloned::(target) { + match server.state.read_component_copied::(target) { Some(pos) => { ecs.read_resource::>() .emit_now(ServerEvent::Explosion { pos: pos.0, power, owner: ecs.read_storage::().get(target).copied(), + friendly_damage: true, }) }, None => server.notify_client( @@ -984,7 +1021,7 @@ fn handle_waypoint( _args: String, _action: &ChatCommand, ) { - match server.state.read_component_cloned::(target) { + match server.state.read_component_copied::(target) { Some(pos) => { let time = server.state.ecs().read_resource(); let _ = server @@ -1020,7 +1057,7 @@ fn handle_adminify( Some(player) => { let is_admin = if server .state - .read_component_cloned::(player) + .read_component_copied::(player) .is_some() { ecs.write_storage::().remove(player); @@ -1161,8 +1198,8 @@ fn handle_group( return; } let ecs = server.state.ecs(); - if let Some(comp::Group(group)) = ecs.read_storage().get(client) { - let mode = comp::ChatMode::Group(group.to_string()); + if let Some(group) = ecs.read_storage::().get(client) { + let mode = comp::ChatMode::Group(*group); let _ = ecs.write_storage().insert(client, mode.clone()); if !msg.is_empty() { if let Some(uid) = ecs.read_storage().get(client) { @@ -1172,7 +1209,7 @@ fn handle_group( } else { server.notify_client( client, - ChatType::CommandError.server_msg("Please join a group with /join_group"), + ChatType::CommandError.server_msg("Please create a group first"), ); } } @@ -1323,68 +1360,6 @@ fn handle_join_faction( } } -fn handle_join_group( - server: &mut Server, - client: EcsEntity, - target: EcsEntity, - args: String, - action: &ChatCommand, -) { - if client != target { - // This happens when [ab]using /sudo - server.notify_client( - client, - ChatType::CommandError.server_msg("It's rude to impersonate people"), - ); - return; - } - if let Some(alias) = server - .state - .ecs() - .read_storage::() - .get(target) - .map(|player| player.alias.clone()) - { - let group_leave = if let Ok(group) = scan_fmt!(&args, &action.arg_fmt(), String) { - let mode = comp::ChatMode::Group(group.clone()); - let _ = server.state.ecs().write_storage().insert(client, mode); - let group_leave = server - .state - .ecs() - .write_storage() - .insert(client, comp::Group(group.clone())) - .ok() - .flatten() - .map(|f| f.0); - server.state.send_chat( - ChatType::GroupMeta(group.clone()) - .chat_msg(format!("[{}] joined group ({})", alias, group)), - ); - group_leave - } else { - let mode = comp::ChatMode::default(); - let _ = server.state.ecs().write_storage().insert(client, mode); - server - .state - .ecs() - .write_storage() - .remove(client) - .map(|comp::Group(f)| f) - }; - if let Some(group) = group_leave { - server.state.send_chat( - ChatType::GroupMeta(group.clone()) - .chat_msg(format!("[{}] left group ({})", alias, group)), - ); - } - } else { - server.notify_client( - client, - ChatType::CommandError.server_msg("Could not find your player alias"), - ); - } -} - #[cfg(not(feature = "worldgen"))] fn handle_debug_column( server: &mut Server, @@ -1626,7 +1601,7 @@ fn handle_remove_lights( action: &ChatCommand, ) { let opt_radius = scan_fmt_some!(&args, &action.arg_fmt(), f32); - let opt_player_pos = server.state.read_component_cloned::(target); + let opt_player_pos = server.state.read_component_copied::(target); let mut to_delete = vec![]; match opt_player_pos { diff --git a/server/src/events/entity_creation.rs b/server/src/events/entity_creation.rs index fb8606e37a..b2924d9d15 100644 --- a/server/src/events/entity_creation.rs +++ b/server/src/events/entity_creation.rs @@ -1,7 +1,7 @@ use crate::{sys, Server, StateExt}; use common::{ comp::{ - self, Agent, Alignment, Body, Gravity, Item, ItemDrop, LightEmitter, Loadout, Pos, + self, group, Agent, Alignment, Body, Gravity, Item, ItemDrop, LightEmitter, Loadout, Pos, Projectile, Scale, Stats, Vel, WaypointArea, }, util::Dir, @@ -36,12 +36,26 @@ pub fn handle_create_npc( scale: Scale, drop_item: Option, ) { + let group = match alignment { + Alignment::Wild => None, + Alignment::Enemy => Some(group::ENEMY), + Alignment::Npc | Alignment::Tame => Some(group::NPC), + // TODO: handle + Alignment::Owned(_) => None, + }; + let entity = server .state .create_npc(pos, stats, loadout, body) .with(scale) .with(alignment); + let entity = if let Some(group) = group { + entity.with(group) + } else { + entity + }; + let entity = if let Some(agent) = agent.into() { entity.with(agent) } else { diff --git a/server/src/events/entity_manipulation.rs b/server/src/events/entity_manipulation.rs index 259cd87859..0a73123a28 100644 --- a/server/src/events/entity_manipulation.rs +++ b/server/src/events/entity_manipulation.rs @@ -2,17 +2,17 @@ use crate::{client::Client, Server, SpawnPoint, StateExt}; use common::{ assets, comp::{ - self, item::lottery::Lottery, object, Body, Damage, DamageSource, HealthChange, - HealthSource, Player, Stats, + self, item::lottery::Lottery, object, Alignment, Body, Damage, DamageSource, Group, + HealthChange, HealthSource, Player, Pos, Stats, }, msg::{PlayerListUpdate, ServerMsg}, state::BlockChange, - sync::{Uid, WorldSyncExt}, + sync::{Uid, UidAllocator, WorldSyncExt}, sys::combat::BLOCK_ANGLE, terrain::{Block, TerrainGrid}, vol::{ReadVol, Vox}, }; -use specs::{join::Join, Entity as EcsEntity, WorldExt}; +use specs::{join::Join, saveload::MarkerAllocator, Entity as EcsEntity, WorldExt}; use tracing::error; use vek::Vec3; @@ -55,28 +55,88 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, cause: HealthSourc state.notify_registered_clients(comp::ChatType::Kill.server_msg(msg)); } - { - // Give EXP to the killer if entity had stats + // Give EXP to the killer if entity had stats + (|| { let mut stats = state.ecs().write_storage::(); - if let Some(entity_stats) = stats.get(entity).cloned() { - if let HealthSource::Attack { by } | HealthSource::Projectile { owner: Some(by) } = - cause - { - state.ecs().entity_from_uid(by.into()).map(|attacker| { - if let Some(attacker_stats) = stats.get_mut(attacker) { - // TODO: Discuss whether we should give EXP by Player - // Killing or not. - attacker_stats.exp.change_by( - (entity_stats.body_type.base_exp() - + entity_stats.level.level() - * entity_stats.body_type.base_exp_increase()) - as i64, - ); - } - }); - } + let by = if let HealthSource::Attack { by } | HealthSource::Projectile { owner: Some(by) } = + cause + { + by + } else { + return; + }; + let attacker = if let Some(attacker) = state.ecs().entity_from_uid(by.into()) { + attacker + } else { + return; + }; + let entity_stats = if let Some(entity_stats) = stats.get(entity) { + entity_stats + } else { + return; + }; + + let groups = state.ecs().read_storage::(); + let attacker_group = groups.get(attacker); + let destroyed_group = groups.get(entity); + // Don't give exp if attacker destroyed themselves or one of their group members + if (attacker_group.is_some() && attacker_group == destroyed_group) || attacker == entity { + return; } - } + + // Maximum distance for other group members to receive exp + const MAX_EXP_DIST: f32 = 150.0; + // Attacker gets same as exp of everyone else + const ATTACKER_EXP_WEIGHT: f32 = 1.0; + let mut exp_reward = (entity_stats.body_type.base_exp() + + entity_stats.level.level() * entity_stats.body_type.base_exp_increase()) + as f32; + + // Distribute EXP to group + let positions = state.ecs().read_storage::(); + let alignments = state.ecs().read_storage::(); + let uids = state.ecs().read_storage::(); + if let (Some(attacker_group), Some(pos)) = (attacker_group, positions.get(entity)) { + // TODO: rework if change to groups makes it easier to iterate entities in a + // group + let mut num_not_pets_in_range = 0; + let members_in_range = ( + &state.ecs().entities(), + &groups, + &positions, + alignments.maybe(), + &uids, + ) + .join() + .filter(|(entity, group, member_pos, _, _)| { + // Check if: in group, not main attacker, and in range + *group == attacker_group + && *entity != attacker + && pos.0.distance_squared(member_pos.0) < MAX_EXP_DIST.powi(2) + }) + .map(|(entity, _, _, alignment, uid)| { + if !matches!(alignment, Some(Alignment::Owned(owner)) if owner != uid) { + num_not_pets_in_range += 1; + } + + entity + }) + .collect::>(); + let exp = exp_reward / (num_not_pets_in_range as f32 + ATTACKER_EXP_WEIGHT); + exp_reward = exp * ATTACKER_EXP_WEIGHT; + members_in_range.into_iter().for_each(|e| { + if let Some(stats) = stats.get_mut(e) { + stats.exp.change_by(exp.ceil() as i64); + } + }); + } + + if let Some(attacker_stats) = stats.get_mut(attacker) { + // TODO: Discuss whether we should give EXP by Player + // Killing or not. + attacker_stats.exp.change_by(exp_reward.ceil() as i64); + } + })(); if state .ecs() @@ -189,7 +249,7 @@ pub fn handle_respawn(server: &Server, entity: EcsEntity) { .is_some() { let respawn_point = state - .read_component_cloned::(entity) + .read_component_copied::(entity) .map(|wp| wp.get_pos()) .unwrap_or(state.ecs().read_resource::().0); @@ -217,11 +277,25 @@ pub fn handle_respawn(server: &Server, entity: EcsEntity) { } } -pub fn handle_explosion(server: &Server, pos: Vec3, power: f32, owner: Option) { +pub fn handle_explosion( + server: &Server, + pos: Vec3, + power: f32, + owner: Option, + friendly_damage: bool, +) { // Go through all other entities let hit_range = 3.0 * power; let ecs = &server.state.ecs(); - for (pos_b, ori_b, character_b, stats_b, loadout_b) in ( + + let owner_entity = owner.and_then(|uid| { + ecs.read_resource::() + .retrieve_entity_internal(uid.into()) + }); + let groups = ecs.read_storage::(); + + for (entity_b, pos_b, ori_b, character_b, stats_b, loadout_b) in ( + &ecs.entities(), &ecs.read_storage::(), &ecs.read_storage::(), ecs.read_storage::().maybe(), @@ -233,9 +307,13 @@ pub fn handle_explosion(server: &Server, pos: Vec3, power: f32, owner: Opti let distance_squared = pos.distance_squared(pos_b.0); // Check if it is a hit if !stats_b.is_dead - // Spherical wedge shaped attack field // RADIUS && distance_squared < hit_range.powi(2) + // Skip if they are in the same group and friendly_damage is turned off for the + // explosion + && (friendly_damage || !owner_entity + .and_then(|e| groups.get(e)) + .map_or(false, |group_a| Some(group_a) == groups.get(entity_b))) { // Weapon gives base damage let dmg = (1.0 - distance_squared / hit_range.powi(2)) * power * 130.0; diff --git a/server/src/events/group_manip.rs b/server/src/events/group_manip.rs new file mode 100644 index 0000000000..fb028729de --- /dev/null +++ b/server/src/events/group_manip.rs @@ -0,0 +1,452 @@ +use crate::{client::Client, Server}; +use common::{ + comp::{ + self, + group::{self, Group, GroupManager, Invite, PendingInvites}, + ChatType, GroupManip, + }, + msg::{InviteAnswer, ServerMsg}, + sync, + sync::WorldSyncExt, +}; +use specs::world::WorldExt; +use std::time::{Duration, Instant}; +use tracing::{error, warn}; + +/// Time before invite times out +const INVITE_TIMEOUT_DUR: Duration = Duration::from_secs(31); +/// Reduced duration shown to the client to help alleviate latency issues +const PRESENTED_INVITE_TIMEOUT_DUR: Duration = Duration::from_secs(30); + +// TODO: turn chat messages into enums +pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupManip) { + let max_group_size = server.settings().max_player_group_size; + let state = server.state_mut(); + + match manip { + GroupManip::Invite(uid) => { + let mut clients = state.ecs().write_storage::(); + let invitee = match state.ecs().entity_from_uid(uid.into()) { + Some(t) => t, + None => { + // Inform of failure + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta + .server_msg("Invite failed, target does not exist.".to_owned()), + ); + } + return; + }, + }; + + let uids = state.ecs().read_storage::(); + + // Check if entity is trying to invite themselves to a group + if uids + .get(entity) + .map_or(false, |inviter_uid| *inviter_uid == uid) + { + warn!("Entity tried to invite themselves into a group"); + return; + } + + // Disallow inviting entity that is already in your group + let groups = state.ecs().read_storage::(); + let group_manager = state.ecs().read_resource::(); + let already_in_same_group = groups.get(entity).map_or(false, |group| { + group_manager + .group_info(*group) + .map_or(false, |g| g.leader == entity) + && groups.get(invitee) == Some(group) + }); + if already_in_same_group { + // Inform of failure + if let Some(client) = clients.get_mut(entity) { + client.notify(ChatType::Meta.server_msg( + "Invite failed, can't invite someone already in your group".to_owned(), + )); + } + return; + } + + let mut pending_invites = state.ecs().write_storage::(); + + // Check if group max size is already reached + // Adding the current number of pending invites + let group_size_limit_reached = state + .ecs() + .read_storage() + .get(entity) + .copied() + .and_then(|group| { + // If entity is currently the leader of a full group then they can't invite + // anyone else + group_manager + .group_info(group) + .filter(|i| i.leader == entity) + .map(|i| i.num_members) + }) + .unwrap_or(1) as usize + + pending_invites.get(entity).map_or(0, |p| p.0.len()) + >= max_group_size as usize; + if group_size_limit_reached { + // Inform inviter that they have reached the group size limit + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta.server_msg( + "Invite failed, pending invites plus current group size have reached \ + the group size limit" + .to_owned(), + ), + ); + } + return; + } + + let agents = state.ecs().read_storage::(); + let mut invites = state.ecs().write_storage::(); + + if invites.contains(invitee) { + // Inform inviter that there is already an invite + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta + .server_msg("This player already has a pending invite.".to_owned()), + ); + } + return; + } + + let mut invite_sent = false; + // Returns true if insertion was succesful + let mut send_invite = || { + match invites.insert(invitee, group::Invite(entity)) { + Err(err) => { + error!("Failed to insert Invite component: {:?}", err); + false + }, + Ok(_) => { + match pending_invites.entry(entity) { + Ok(entry) => { + entry + .or_insert_with(|| PendingInvites(Vec::new())) + .0 + .push((invitee, Instant::now() + INVITE_TIMEOUT_DUR)); + invite_sent = true; + true + }, + Err(err) => { + error!( + "Failed to get entry for pending invites component: {:?}", + err + ); + // Cleanup + invites.remove(invitee); + false + }, + } + }, + } + }; + + // If client comp + if let (Some(client), Some(inviter)) = + (clients.get_mut(invitee), uids.get(entity).copied()) + { + if send_invite() { + client.notify(ServerMsg::GroupInvite { + inviter, + timeout: PRESENTED_INVITE_TIMEOUT_DUR, + }); + } + } else if agents.contains(invitee) { + send_invite(); + } else if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta.server_msg("Can't invite, not a player or npc".to_owned()), + ); + } + + // Notify inviter that the invite is pending + if invite_sent { + if let Some(client) = clients.get_mut(entity) { + client.notify(ServerMsg::InvitePending(uid)); + } + } + }, + GroupManip::Accept => { + let mut clients = state.ecs().write_storage::(); + let uids = state.ecs().read_storage::(); + let mut invites = state.ecs().write_storage::(); + if let Some(inviter) = invites.remove(entity).and_then(|invite| { + let inviter = invite.0; + let mut pending_invites = state.ecs().write_storage::(); + let pending = &mut pending_invites.get_mut(inviter)?.0; + // Check that inviter has a pending invite and remove it from the list + let invite_index = pending.iter().position(|p| p.0 == entity)?; + pending.swap_remove(invite_index); + // If no pending invites remain remove the component + if pending.is_empty() { + pending_invites.remove(inviter); + } + + Some(inviter) + }) { + if let (Some(client), Some(target)) = + (clients.get_mut(inviter), uids.get(entity).copied()) + { + client.notify(ServerMsg::InviteComplete { + target, + answer: InviteAnswer::Accepted, + }) + } + let mut group_manager = state.ecs().write_resource::(); + group_manager.add_group_member( + inviter, + entity, + &state.ecs().entities(), + &mut state.ecs().write_storage(), + &state.ecs().read_storage(), + &uids, + |entity, group_change| { + clients + .get_mut(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| c.notify(ServerMsg::GroupUpdate(g))); + }, + ); + } + }, + GroupManip::Decline => { + let mut clients = state.ecs().write_storage::(); + let uids = state.ecs().read_storage::(); + let mut invites = state.ecs().write_storage::(); + if let Some(inviter) = invites.remove(entity).and_then(|invite| { + let inviter = invite.0; + let mut pending_invites = state.ecs().write_storage::(); + let pending = &mut pending_invites.get_mut(inviter)?.0; + // Check that inviter has a pending invite and remove it from the list + let invite_index = pending.iter().position(|p| p.0 == entity)?; + pending.swap_remove(invite_index); + // If no pending invites remain remove the component + if pending.is_empty() { + pending_invites.remove(inviter); + } + + Some(inviter) + }) { + // Inform inviter of rejection + if let (Some(client), Some(target)) = + (clients.get_mut(inviter), uids.get(entity).copied()) + { + client.notify(ServerMsg::InviteComplete { + target, + answer: InviteAnswer::Declined, + }) + } + } + }, + GroupManip::Leave => { + let mut clients = state.ecs().write_storage::(); + let uids = state.ecs().read_storage::(); + let mut group_manager = state.ecs().write_resource::(); + group_manager.leave_group( + entity, + &mut state.ecs().write_storage(), + &state.ecs().read_storage(), + &uids, + &state.ecs().entities(), + &mut |entity, group_change| { + clients + .get_mut(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| c.notify(ServerMsg::GroupUpdate(g))); + }, + ); + }, + GroupManip::Kick(uid) => { + let mut clients = state.ecs().write_storage::(); + let uids = state.ecs().read_storage::(); + let alignments = state.ecs().read_storage::(); + + let target = match state.ecs().entity_from_uid(uid.into()) { + Some(t) => t, + None => { + // Inform of failure + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta + .server_msg("Kick failed, target does not exist.".to_owned()), + ); + } + return; + }, + }; + + // Can't kick pet + if matches!(alignments.get(target), Some(comp::Alignment::Owned(owner)) if uids.get(target).map_or(true, |u| u != owner)) + { + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta.server_msg("Kick failed, you can't kick pets.".to_owned()), + ); + } + return; + } + // Can't kick yourself + if uids.get(entity).map_or(false, |u| *u == uid) { + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta + .server_msg("Kick failed, you can't kick yourself.".to_owned()), + ); + } + return; + } + + let mut groups = state.ecs().write_storage::(); + let mut group_manager = state.ecs().write_resource::(); + // Make sure kicker is the group leader + match groups + .get(target) + .and_then(|group| group_manager.group_info(*group)) + { + Some(info) if info.leader == entity => { + // Remove target from group + group_manager.leave_group( + target, + &mut groups, + &state.ecs().read_storage(), + &uids, + &state.ecs().entities(), + &mut |entity, group_change| { + clients + .get_mut(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| c.notify(ServerMsg::GroupUpdate(g))); + }, + ); + + // Tell them the have been kicked + if let Some(client) = clients.get_mut(target) { + client.notify( + ChatType::Meta + .server_msg("You were removed from the group.".to_owned()), + ); + } + // Tell kicker that they were succesful + if let Some(client) = clients.get_mut(entity) { + client.notify(ChatType::Meta.server_msg("Player kicked.".to_owned())); + } + }, + Some(_) => { + // Inform kicker that they are not the leader + if let Some(client) = clients.get_mut(entity) { + client.notify(ChatType::Meta.server_msg( + "Kick failed: You are not the leader of the target's group.".to_owned(), + )); + } + }, + None => { + // Inform kicker that the target is not in a group + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta.server_msg( + "Kick failed: Your target is not in a group.".to_owned(), + ), + ); + } + }, + } + }, + GroupManip::AssignLeader(uid) => { + let mut clients = state.ecs().write_storage::(); + let uids = state.ecs().read_storage::(); + let target = match state.ecs().entity_from_uid(uid.into()) { + Some(t) => t, + None => { + // Inform of failure + if let Some(client) = clients.get_mut(entity) { + client.notify(ChatType::Meta.server_msg( + "Leadership transfer failed, target does not exist".to_owned(), + )); + } + return; + }, + }; + let groups = state.ecs().read_storage::(); + let mut group_manager = state.ecs().write_resource::(); + // Make sure assigner is the group leader + match groups + .get(target) + .and_then(|group| group_manager.group_info(*group)) + { + Some(info) if info.leader == entity => { + // Assign target as group leader + group_manager.assign_leader( + target, + &groups, + &state.ecs().entities(), + &state.ecs().read_storage(), + &uids, + |entity, group_change| { + clients + .get_mut(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| c.notify(ServerMsg::GroupUpdate(g))); + }, + ); + // Tell them they are the leader + if let Some(client) = clients.get_mut(target) { + client.notify( + ChatType::Meta.server_msg("You are the group leader now.".to_owned()), + ); + } + // Tell the old leader that the transfer was succesful + if let Some(client) = clients.get_mut(target) { + client.notify( + ChatType::Meta + .server_msg("You are no longer the group leader.".to_owned()), + ); + } + }, + Some(_) => { + // Inform transferer that they are not the leader + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta.server_msg( + "Transfer failed: You are not the leader of the target's group." + .to_owned(), + ), + ); + } + }, + None => { + // Inform transferer that the target is not in a group + if let Some(client) = clients.get_mut(entity) { + client.notify(ChatType::Meta.server_msg( + "Transfer failed: Your target is not in a group.".to_owned(), + )); + } + }, + } + }, + } +} diff --git a/server/src/events/inventory_manip.rs b/server/src/events/inventory_manip.rs index 119ad8429b..549804d6f8 100644 --- a/server/src/events/inventory_manip.rs +++ b/server/src/events/inventory_manip.rs @@ -1,10 +1,11 @@ -use crate::{Server, StateExt}; +use crate::{client::Client, Server, StateExt}; use common::{ comp::{ self, item, slot::{self, Slot}, Pos, MAX_PICKUP_RANGE_SQR, }, + msg::ServerMsg, recipe::default_recipe_book, sync::{Uid, WorldSyncExt}, terrain::block::Block, @@ -166,10 +167,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv thrown_items.push(( *pos, state - .read_component_cloned::(entity) + .read_component_copied::(entity) .unwrap_or_default(), state - .read_component_cloned::(entity) + .read_component_copied::(entity) .unwrap_or_default(), *kind, )); @@ -184,7 +185,7 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv state.read_storage::().get(entity) { let uid = state - .read_component_cloned(entity) + .read_component_copied(entity) .expect("Expected player to have a UID"); if ( &state.read_storage::(), @@ -222,6 +223,35 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv .ecs() .write_storage() .insert(tameable_entity, comp::Alignment::Owned(uid)); + + // Add to group system + let mut clients = state.ecs().write_storage::(); + let uids = state.ecs().read_storage::(); + let mut group_manager = state + .ecs() + .write_resource::( + ); + group_manager.new_pet( + tameable_entity, + entity, + &mut state.ecs().write_storage(), + &state.ecs().entities(), + &state.ecs().read_storage(), + &uids, + &mut |entity, group_change| { + clients + .get_mut(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| { + c.notify(ServerMsg::GroupUpdate(g)) + }); + }, + ); + let _ = state .ecs() .write_storage() @@ -311,7 +341,7 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv dropped_items.push(( *pos, state - .read_component_cloned::(entity) + .read_component_copied::(entity) .unwrap_or_default(), item, )); @@ -343,10 +373,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv for _ in 0..amount { dropped_items.push(( state - .read_component_cloned::(entity) + .read_component_copied::(entity) .unwrap_or_default(), state - .read_component_cloned::(entity) + .read_component_copied::(entity) .unwrap_or_default(), item.clone(), )); @@ -377,7 +407,7 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv + Vec3::unit_z() * 15.0 + Vec3::::zero().map(|_| rand::thread_rng().gen::() - 0.5) * 4.0; - let uid = state.read_component_cloned::(entity); + let uid = state.read_component_copied::(entity); let mut new_entity = state .create_object(Default::default(), match kind { diff --git a/server/src/events/mod.rs b/server/src/events/mod.rs index fb26dad411..412d9a536a 100644 --- a/server/src/events/mod.rs +++ b/server/src/events/mod.rs @@ -8,6 +8,7 @@ use entity_manipulation::{ handle_damage, handle_destroy, handle_explosion, handle_land_on_ground, handle_level_up, handle_respawn, }; +use group_manip::handle_group; use interaction::{handle_lantern, handle_mount, handle_possess, handle_unmount}; use inventory_manip::handle_inventory; use player::{handle_client_disconnect, handle_exit_ingame}; @@ -15,6 +16,7 @@ use specs::{Entity as EcsEntity, WorldExt}; mod entity_creation; mod entity_manipulation; +mod group_manip; mod interaction; mod inventory_manip; mod player; @@ -48,9 +50,12 @@ impl Server { for event in events { match event { - ServerEvent::Explosion { pos, power, owner } => { - handle_explosion(&self, pos, power, owner) - }, + ServerEvent::Explosion { + pos, + power, + owner, + friendly_damage, + } => handle_explosion(&self, pos, power, owner, friendly_damage), ServerEvent::Shoot { entity, dir, @@ -62,6 +67,7 @@ impl Server { ServerEvent::Damage { uid, change } => handle_damage(&self, uid, change), ServerEvent::Destroy { entity, cause } => handle_destroy(self, entity, cause), ServerEvent::InventoryManip(entity, manip) => handle_inventory(self, entity, manip), + ServerEvent::GroupManip(entity, manip) => handle_group(self, entity, manip), ServerEvent::Respawn(entity) => handle_respawn(&self, entity), ServerEvent::LandOnGround { entity, vel } => { handle_land_on_ground(&self, entity, vel) diff --git a/server/src/events/player.rs b/server/src/events/player.rs index 0e7bd7120d..2c9a06e54a 100644 --- a/server/src/events/player.rs +++ b/server/src/events/player.rs @@ -4,7 +4,7 @@ use crate::{ }; use common::{ comp, - comp::Player, + comp::{group, Player}, msg::{ClientState, PlayerListUpdate, ServerMsg}, sync::{Uid, UidAllocator}, }; @@ -20,8 +20,13 @@ pub fn handle_exit_ingame(server: &mut Server, entity: EcsEntity) { // Note: If other `ServerEvent`s are referring to this entity they will be // disrupted let maybe_client = state.ecs().write_storage::().remove(entity); - let maybe_uid = state.read_component_cloned::(entity); + let maybe_uid = state.read_component_copied::(entity); let maybe_player = state.ecs().write_storage::().remove(entity); + let maybe_group = state + .ecs() + .write_storage::() + .get(entity) + .cloned(); if let (Some(mut client), Some(uid), Some(player)) = (maybe_client, maybe_uid, maybe_player) { // Tell client its request was successful client.allow_state(ClientState::Registered); @@ -29,13 +34,39 @@ pub fn handle_exit_ingame(server: &mut Server, entity: EcsEntity) { client.notify(ServerMsg::ExitIngameCleanup); let entity_builder = state.ecs_mut().create_entity().with(client).with(player); + + let entity_builder = match maybe_group { + Some(group) => entity_builder.with(group), + None => entity_builder, + }; + // Ensure UidAllocator maps this uid to the new entity let uid = entity_builder .world .write_resource::() .allocate(entity_builder.entity, Some(uid.into())); - entity_builder.with(uid).build(); + let new_entity = entity_builder.with(uid).build(); + if let Some(group) = maybe_group { + let mut group_manager = state.ecs().write_resource::(); + if group_manager + .group_info(group) + .map(|info| info.leader == entity) + .unwrap_or(false) + { + group_manager.assign_leader( + new_entity, + &state.ecs().read_storage(), + &state.ecs().entities(), + &state.ecs().read_storage(), + &state.ecs().read_storage(), + // Nothing actually changing since Uid is transferred + |_, _| {}, + ); + } + } } + // Erase group component to avoid group restructure when deleting the entity + state.ecs().write_storage::().remove(entity); // Delete old entity if let Err(e) = state.delete_entity_recorded(entity) { error!( diff --git a/server/src/lib.rs b/server/src/lib.rs index 2f5fff5ae9..7d27b933ab 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,6 +1,6 @@ #![deny(unsafe_code)] #![allow(clippy::option_map_unit_fn)] -#![feature(drain_filter, option_zip)] +#![feature(bool_to_option, drain_filter, option_zip)] pub mod alias_validator; pub mod chunk_generator; @@ -127,6 +127,7 @@ impl Server { state.ecs_mut().insert(sys::TerrainSyncTimer::default()); state.ecs_mut().insert(sys::TerrainTimer::default()); state.ecs_mut().insert(sys::WaypointTimer::default()); + state.ecs_mut().insert(sys::InviteTimeoutTimer::default()); state.ecs_mut().insert(sys::PersistenceTimer::default()); // System schedulers to control execution of systems @@ -508,12 +509,18 @@ impl Server { .nanos as i64; let terrain_nanos = self.state.ecs().read_resource::().nanos as i64; let waypoint_nanos = self.state.ecs().read_resource::().nanos as i64; + let invite_timeout_nanos = self + .state + .ecs() + .read_resource::() + .nanos as i64; let stats_persistence_nanos = self .state .ecs() .read_resource::() .nanos as i64; - let total_sys_ran_in_dispatcher_nanos = terrain_nanos + waypoint_nanos; + let total_sys_ran_in_dispatcher_nanos = + terrain_nanos + waypoint_nanos + invite_timeout_nanos; // Report timing info self.tick_metrics @@ -575,6 +582,10 @@ impl Server { .tick_time .with_label_values(&["waypoint"]) .set(waypoint_nanos); + self.tick_metrics + .tick_time + .with_label_values(&["invite timeout"]) + .set(invite_timeout_nanos); self.tick_metrics .tick_time .with_label_values(&["persistence:stats"]) @@ -684,6 +695,7 @@ impl Server { .create_entity_package(entity, None, None, None), server_info: self.get_server_info(), time_of_day: *self.state.ecs().read_resource(), + max_group_size: self.settings().max_player_group_size, world_map: (WORLD_SIZE.map(|e| e as u32), self.map.clone()), recipe_book: (&*default_recipe_book()).clone(), }); diff --git a/server/src/settings.rs b/server/src/settings.rs index 8bc0c6f3a5..06dec6e3a9 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -26,6 +26,7 @@ pub struct ServerSettings { pub persistence_db_dir: String, pub max_view_distance: Option, pub banned_words_files: Vec, + pub max_player_group_size: u32, } impl Default for ServerSettings { @@ -65,6 +66,7 @@ impl Default for ServerSettings { persistence_db_dir: "saves".to_owned(), max_view_distance: Some(30), banned_words_files: Vec::new(), + max_player_group_size: 6, } } } diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs index 645e7843d4..77b42d53a0 100644 --- a/server/src/state_ext.rs +++ b/server/src/state_ext.rs @@ -46,7 +46,7 @@ pub trait StateExt { /// Performed after loading component data from the database fn update_character_data(&mut self, entity: EcsEntity, components: PersistedComponents); /// Iterates over registered clients and send each `ServerMsg` - fn send_chat(&self, msg: comp::ChatMsg); + fn send_chat(&self, msg: comp::UnresolvedChatMsg); fn notify_registered_clients(&self, msg: ServerMsg); /// Delete an entity, recording the deletion in [`DeletedEntities`] fn delete_entity_recorded( @@ -173,7 +173,7 @@ impl StateExt for State { self.write_component(entity, comp::CharacterState::default()); self.write_component( entity, - comp::Alignment::Owned(self.read_component_cloned(entity).unwrap()), + comp::Alignment::Owned(self.read_component_copied(entity).unwrap()), ); // Set the character id for the player @@ -213,7 +213,7 @@ impl StateExt for State { // Notify clients of a player list update let client_uid = self - .read_component_cloned::(entity) + .read_component_copied::(entity) .map(|u| u) .expect("Client doesn't have a Uid!!!"); @@ -240,10 +240,18 @@ impl StateExt for State { /// Send the chat message to the proper players. Say and region are limited /// by location. Faction and group are limited by component. - fn send_chat(&self, msg: comp::ChatMsg) { + fn send_chat(&self, msg: comp::UnresolvedChatMsg) { let ecs = self.ecs(); let is_within = |target, a: &comp::Pos, b: &comp::Pos| a.0.distance_squared(b.0) < target * target; + + let group_manager = ecs.read_resource::(); + let resolved_msg = msg.clone().map_group(|group_id| { + group_manager + .group_info(group_id) + .map_or_else(|| "???".into(), |i| i.name.clone()) + }); + match &msg.chat_type { comp::ChatType::Online | comp::ChatType::Offline @@ -253,7 +261,7 @@ impl StateExt for State { | comp::ChatType::Kill | comp::ChatType::Meta | comp::ChatType::World(_) => { - self.notify_registered_clients(ServerMsg::ChatMsg(msg.clone())) + self.notify_registered_clients(ServerMsg::ChatMsg(resolved_msg)) }, comp::ChatType::Tell(u, t) => { for (client, uid) in ( @@ -263,7 +271,7 @@ impl StateExt for State { .join() { if uid == u || uid == t { - client.notify(ServerMsg::ChatMsg(msg.clone())); + client.notify(ServerMsg::ChatMsg(resolved_msg.clone())); } } }, @@ -275,7 +283,7 @@ impl StateExt for State { if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { for (client, pos) in (&mut ecs.write_storage::(), &positions).join() { if is_within(comp::ChatMsg::SAY_DISTANCE, pos, speaker_pos) { - client.notify(ServerMsg::ChatMsg(msg.clone())); + client.notify(ServerMsg::ChatMsg(resolved_msg.clone())); } } } @@ -287,7 +295,7 @@ impl StateExt for State { if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { for (client, pos) in (&mut ecs.write_storage::(), &positions).join() { if is_within(comp::ChatMsg::REGION_DISTANCE, pos, speaker_pos) { - client.notify(ServerMsg::ChatMsg(msg.clone())); + client.notify(ServerMsg::ChatMsg(resolved_msg.clone())); } } } @@ -299,7 +307,7 @@ impl StateExt for State { if let Some(speaker_pos) = entity_opt.and_then(|e| positions.get(e)) { for (client, pos) in (&mut ecs.write_storage::(), &positions).join() { if is_within(comp::ChatMsg::NPC_DISTANCE, pos, speaker_pos) { - client.notify(ServerMsg::ChatMsg(msg.clone())); + client.notify(ServerMsg::ChatMsg(resolved_msg.clone())); } } } @@ -313,19 +321,19 @@ impl StateExt for State { .join() { if s == &faction.0 { - client.notify(ServerMsg::ChatMsg(msg.clone())); + client.notify(ServerMsg::ChatMsg(resolved_msg.clone())); } } }, - comp::ChatType::GroupMeta(s) | comp::ChatType::Group(_, s) => { + comp::ChatType::GroupMeta(g) | comp::ChatType::Group(_, g) => { for (client, group) in ( &mut ecs.write_storage::(), &ecs.read_storage::(), ) .join() { - if s == &group.0 { - client.notify(ServerMsg::ChatMsg(msg.clone())); + if g == group { + client.notify(ServerMsg::ChatMsg(resolved_msg.clone())); } } }, @@ -346,6 +354,30 @@ impl StateExt for State { &mut self, entity: EcsEntity, ) -> Result<(), specs::error::WrongGeneration> { + // Remove entity from a group if they are in one + { + let mut clients = self.ecs().write_storage::(); + let uids = self.ecs().read_storage::(); + let mut group_manager = self.ecs().write_resource::(); + group_manager.entity_deleted( + entity, + &mut self.ecs().write_storage(), + &self.ecs().read_storage(), + &uids, + &self.ecs().entities(), + &mut |entity, group_change| { + clients + .get_mut(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| c.notify(ServerMsg::GroupUpdate(g))); + }, + ); + } + let (maybe_uid, maybe_pos) = ( self.ecs().read_storage::().get(entity).copied(), self.ecs().read_storage::().get(entity).copied(), diff --git a/server/src/sys/invite_timeout.rs b/server/src/sys/invite_timeout.rs new file mode 100644 index 0000000000..7a701b03a4 --- /dev/null +++ b/server/src/sys/invite_timeout.rs @@ -0,0 +1,71 @@ +use super::SysTimer; +use crate::client::Client; +use common::{ + comp::group::{Invite, PendingInvites}, + msg::{InviteAnswer, ServerMsg}, + sync::Uid, +}; +use specs::{Entities, Join, ReadStorage, System, Write, WriteStorage}; + +/// This system removes timed out group invites +pub struct Sys; +impl<'a> System<'a> for Sys { + #[allow(clippy::type_complexity)] // TODO: Pending review in #587 + type SystemData = ( + Entities<'a>, + WriteStorage<'a, Invite>, + WriteStorage<'a, PendingInvites>, + WriteStorage<'a, Client>, + ReadStorage<'a, Uid>, + Write<'a, SysTimer>, + ); + + fn run( + &mut self, + (entities, mut invites, mut pending_invites, mut clients, uids, mut timer): Self::SystemData, + ) { + timer.start(); + + let now = std::time::Instant::now(); + + let timed_out_invites = (&entities, &invites) + .join() + .filter_map(|(invitee, Invite(inviter))| { + // Retrieve timeout invite from pending invites + let pending = &mut pending_invites.get_mut(*inviter)?.0; + let index = pending.iter().position(|p| p.0 == invitee)?; + + // Stop if not timed out + if pending[index].1 > now { + return None; + } + + // Remove pending entry + pending.swap_remove(index); + + // If no pending invites remain remove the component + if pending.is_empty() { + pending_invites.remove(*inviter); + } + + // Inform inviter of timeout + if let (Some(client), Some(target)) = + (clients.get_mut(*inviter), uids.get(invitee).copied()) + { + client.notify(ServerMsg::InviteComplete { + target, + answer: InviteAnswer::TimedOut, + }) + } + + Some(invitee) + }) + .collect::>(); + + for entity in timed_out_invites { + invites.remove(entity); + } + + timer.end(); + } +} diff --git a/server/src/sys/message.rs b/server/src/sys/message.rs index d2d7c895d6..0528e8cfba 100644 --- a/server/src/sys/message.rs +++ b/server/src/sys/message.rs @@ -5,8 +5,8 @@ use crate::{ }; use common::{ comp::{ - Admin, AdminList, CanBuild, ChatMode, ChatMsg, ChatType, ControlEvent, Controller, - ForceUpdate, Ori, Player, Pos, Stats, Vel, + Admin, AdminList, CanBuild, ChatMode, ChatType, ControlEvent, Controller, ForceUpdate, Ori, + Player, Pos, Stats, UnresolvedChatMsg, Vel, }, event::{EventBus, ServerEvent}, msg::{ @@ -32,7 +32,7 @@ impl Sys { #[allow(clippy::too_many_arguments)] async fn handle_client_msg( server_emitter: &mut common::event::Emitter<'_, ServerEvent>, - new_chat_msgs: &mut Vec<(Option, ChatMsg)>, + new_chat_msgs: &mut Vec<(Option, UnresolvedChatMsg)>, player_list: &HashMap, new_players: &mut Vec, entity: specs::Entity, @@ -202,7 +202,7 @@ impl Sys { // Only send login message if it wasn't already // sent previously if !client.login_msg_sent { - new_chat_msgs.push((None, ChatMsg { + new_chat_msgs.push((None, UnresolvedChatMsg { chat_type: ChatType::Online, message: format!("[{}] is now online.", &player.alias), // TODO: Localize this })); @@ -461,7 +461,7 @@ impl<'a> System<'a> for Sys { let mut server_emitter = server_event_bus.emitter(); - let mut new_chat_msgs: Vec<(Option, ChatMsg)> = Vec::new(); + let mut new_chat_msgs = Vec::new(); // Player list to send new players. let player_list = (&uids, &players, stats.maybe(), admins.maybe()) diff --git a/server/src/sys/mod.rs b/server/src/sys/mod.rs index 5afef7795c..6b98fc9edb 100644 --- a/server/src/sys/mod.rs +++ b/server/src/sys/mod.rs @@ -1,4 +1,5 @@ pub mod entity_sync; +pub mod invite_timeout; pub mod message; pub mod object; pub mod persistence; @@ -21,6 +22,7 @@ pub type SubscriptionTimer = SysTimer; pub type TerrainTimer = SysTimer; pub type TerrainSyncTimer = SysTimer; pub type WaypointTimer = SysTimer; +pub type InviteTimeoutTimer = SysTimer; pub type PersistenceTimer = SysTimer; pub type PersistenceScheduler = SysScheduler; @@ -32,12 +34,14 @@ pub type PersistenceScheduler = SysScheduler; //const TERRAIN_SYNC_SYS: &str = "server_terrain_sync_sys"; const TERRAIN_SYS: &str = "server_terrain_sys"; const WAYPOINT_SYS: &str = "server_waypoint_sys"; +const INVITE_TIMEOUT_SYS: &str = "server_invite_timeout_sys"; const PERSISTENCE_SYS: &str = "server_persistence_sys"; const OBJECT_SYS: &str = "server_object_sys"; pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) { dispatch_builder.add(terrain::Sys, TERRAIN_SYS, &[]); dispatch_builder.add(waypoint::Sys, WAYPOINT_SYS, &[]); + dispatch_builder.add(invite_timeout::Sys, INVITE_TIMEOUT_SYS, &[]); dispatch_builder.add(persistence::Sys, PERSISTENCE_SYS, &[]); dispatch_builder.add(object::Sys, OBJECT_SYS, &[]); } diff --git a/server/src/sys/object.rs b/server/src/sys/object.rs index be986f8e6e..ea3c2d3ad5 100644 --- a/server/src/sys/object.rs +++ b/server/src/sys/object.rs @@ -39,6 +39,7 @@ impl<'a> System<'a> for Sys { pos: pos.0, power: 4.0, owner: *owner, + friendly_damage: true, }); } }, diff --git a/server/src/sys/sentinel.rs b/server/src/sys/sentinel.rs index 55da254f51..adcc76bd30 100644 --- a/server/src/sys/sentinel.rs +++ b/server/src/sys/sentinel.rs @@ -1,7 +1,7 @@ use super::SysTimer; use common::{ comp::{ - Alignment, Body, CanBuild, CharacterState, Collider, Energy, Gravity, Item, LightEmitter, + Body, CanBuild, CharacterState, Collider, Energy, Gravity, Group, Item, LightEmitter, Loadout, Mass, MountState, Mounting, Ori, Player, Pos, Scale, Stats, Sticky, Vel, }, msg::EcsCompPacket, @@ -48,7 +48,7 @@ pub struct TrackedComps<'a> { pub scale: ReadStorage<'a, Scale>, pub mounting: ReadStorage<'a, Mounting>, pub mount_state: ReadStorage<'a, MountState>, - pub alignment: ReadStorage<'a, Alignment>, + pub group: ReadStorage<'a, Group>, pub mass: ReadStorage<'a, Mass>, pub collider: ReadStorage<'a, Collider>, pub sticky: ReadStorage<'a, Sticky>, @@ -105,7 +105,7 @@ impl<'a> TrackedComps<'a> { .get(entity) .cloned() .map(|c| comps.push(c.into())); - self.alignment + self.group .get(entity) .cloned() .map(|c| comps.push(c.into())); @@ -151,7 +151,7 @@ pub struct ReadTrackers<'a> { pub scale: ReadExpect<'a, UpdateTracker>, pub mounting: ReadExpect<'a, UpdateTracker>, pub mount_state: ReadExpect<'a, UpdateTracker>, - pub alignment: ReadExpect<'a, UpdateTracker>, + pub group: ReadExpect<'a, UpdateTracker>, pub mass: ReadExpect<'a, UpdateTracker>, pub collider: ReadExpect<'a, UpdateTracker>, pub sticky: ReadExpect<'a, UpdateTracker>, @@ -184,7 +184,7 @@ impl<'a> ReadTrackers<'a> { .with_component(&comps.uid, &*self.scale, &comps.scale, filter) .with_component(&comps.uid, &*self.mounting, &comps.mounting, filter) .with_component(&comps.uid, &*self.mount_state, &comps.mount_state, filter) - .with_component(&comps.uid, &*self.alignment, &comps.alignment, filter) + .with_component(&comps.uid, &*self.group, &comps.group, filter) .with_component(&comps.uid, &*self.mass, &comps.mass, filter) .with_component(&comps.uid, &*self.collider, &comps.collider, filter) .with_component(&comps.uid, &*self.sticky, &comps.sticky, filter) @@ -214,7 +214,7 @@ pub struct WriteTrackers<'a> { scale: WriteExpect<'a, UpdateTracker>, mounting: WriteExpect<'a, UpdateTracker>, mount_state: WriteExpect<'a, UpdateTracker>, - alignment: WriteExpect<'a, UpdateTracker>, + group: WriteExpect<'a, UpdateTracker>, mass: WriteExpect<'a, UpdateTracker>, collider: WriteExpect<'a, UpdateTracker>, sticky: WriteExpect<'a, UpdateTracker>, @@ -236,7 +236,7 @@ fn record_changes(comps: &TrackedComps, trackers: &mut WriteTrackers) { trackers.scale.record_changes(&comps.scale); trackers.mounting.record_changes(&comps.mounting); trackers.mount_state.record_changes(&comps.mount_state); - trackers.alignment.record_changes(&comps.alignment); + trackers.group.record_changes(&comps.group); trackers.mass.record_changes(&comps.mass); trackers.collider.record_changes(&comps.collider); trackers.sticky.record_changes(&comps.sticky); @@ -291,7 +291,7 @@ pub fn register_trackers(world: &mut World) { world.register_tracker::(); world.register_tracker::(); world.register_tracker::(); - world.register_tracker::(); + world.register_tracker::(); world.register_tracker::(); world.register_tracker::(); world.register_tracker::(); diff --git a/server/src/test_world.rs b/server/src/test_world.rs index 1b6d070e61..b95fabfb61 100644 --- a/server/src/test_world.rs +++ b/server/src/test_world.rs @@ -1,5 +1,5 @@ use common::{ - generation::{ChunkSupplement, EntityInfo, EntityKind}, + generation::{ChunkSupplement, EntityInfo}, terrain::{Block, BlockKind, TerrainChunk, TerrainChunkMeta, TerrainChunkSize}, vol::{ReadVol, RectVolSize, Vox, WriteVol}, }; @@ -30,17 +30,10 @@ impl World { let mut supplement = ChunkSupplement::default(); - if chunk_pos.map(|e| e % 8 == 0).reduce_and() { - supplement = supplement.with_entity(EntityInfo { - pos: Vec3::::from(chunk_pos.map(|e| e as f32 * 32.0)) + Vec3::unit_z() * 256.0, - kind: EntityKind::Waypoint, - }); - } - Ok(( TerrainChunk::new( 256 + if rng.gen::() < 64 { height } else { 0 }, - Block::new(BlockKind::Dense, Rgb::new(200, 220, 255)), + Block::new(BlockKind::Grass, Rgb::new(11, 102, 35)), Block::empty(), TerrainChunkMeta::void(), ), diff --git a/voxygen/src/hud/bag.rs b/voxygen/src/hud/bag.rs index 0af0b8b244..e3ab174156 100644 --- a/voxygen/src/hud/bag.rs +++ b/voxygen/src/hud/bag.rs @@ -229,7 +229,7 @@ impl<'a> Widget for Bag<'a> { ) .mid_top_with_margin_on(state.ids.bg_frame, 9.0) .font_id(self.fonts.cyri.conrod_id) - .font_size(self.fonts.cyri.scale(22)) + .font_size(self.fonts.cyri.scale(20)) .color(Color::Rgba(0.0, 0.0, 0.0, 1.0)) .set(state.ids.inventory_title_bg, ui); Text::new( @@ -240,7 +240,7 @@ impl<'a> Widget for Bag<'a> { ) .top_left_with_margins_on(state.ids.inventory_title_bg, 2.0, 2.0) .font_id(self.fonts.cyri.conrod_id) - .font_size(self.fonts.cyri.scale(22)) + .font_size(self.fonts.cyri.scale(20)) .color(TEXT_COLOR) .set(state.ids.inventory_title, ui); // Scrollbar-BG @@ -585,7 +585,7 @@ impl<'a> Widget for Bag<'a> { "{}\n\n{}\n\n{}\n\n{}%", self.stats.endurance, self.stats.fitness, self.stats.willpower, damage_reduction )) - .top_right_with_margins_on(state.ids.stats_alignment, 120.0, 150.0) + .top_right_with_margins_on(state.ids.stats_alignment, 120.0, 130.0) .font_id(self.fonts.cyri.conrod_id) .font_size(self.fonts.cyri.scale(16)) .color(TEXT_COLOR) diff --git a/voxygen/src/hud/buttons.rs b/voxygen/src/hud/buttons.rs index 8ae1f6b7c4..2c57697873 100644 --- a/voxygen/src/hud/buttons.rs +++ b/voxygen/src/hud/buttons.rs @@ -41,7 +41,7 @@ widget_ids! { crafting_button_bg, crafting_text, crafting_text_bg, - + group_button, } } #[derive(WidgetCommon)] @@ -360,6 +360,7 @@ impl<'a> Widget for Buttons<'a> { .color(TEXT_COLOR) .set(state.ids.spellbook_text, ui); } + // Crafting if Button::image(self.imgs.crafting_icon) .w_h(25.0, 25.0) @@ -396,6 +397,7 @@ impl<'a> Widget for Buttons<'a> { .color(TEXT_COLOR) .set(state.ids.crafting_text, ui); } + None } } diff --git a/voxygen/src/hud/chat.rs b/voxygen/src/hud/chat.rs index 46267014ad..7b5ac61548 100644 --- a/voxygen/src/hud/chat.rs +++ b/voxygen/src/hud/chat.rs @@ -475,7 +475,7 @@ fn cursor_offset_to_index( } /// Get the color and icon for the current line in the chat box -fn render_chat_line(chat_type: &ChatType, imgs: &Imgs) -> (Color, conrod_core::image::Id) { +fn render_chat_line(chat_type: &ChatType, imgs: &Imgs) -> (Color, conrod_core::image::Id) { match chat_type { ChatType::Online => (ONLINE_COLOR, imgs.chat_online_small), ChatType::Offline => (OFFLINE_COLOR, imgs.chat_offline_small), diff --git a/voxygen/src/hud/crafting.rs b/voxygen/src/hud/crafting.rs index 19fff3dfa6..99c706a5dc 100644 --- a/voxygen/src/hud/crafting.rs +++ b/voxygen/src/hud/crafting.rs @@ -117,23 +117,6 @@ impl<'a> Widget for Crafting<'a> { ) }); } - /*if state.ids.recipe_img_frame.len() < self.client.recipe_book().iter().len() { - state.update(|state| { - state.ids.recipe_img_frame.resize( - self.client.recipe_book().iter().len(), - &mut ui.widget_id_generator(), - ) - }); - } - if state.ids.recipe_img.len() < self.client.recipe_book().iter().len() { - state.update(|state| { - state.ids.recipe_img.resize( - self.client.recipe_book().iter().len(), - &mut ui.widget_id_generator(), - ) - }); - }*/ - let ids = &state.ids; let mut events = Vec::new(); @@ -186,7 +169,7 @@ impl<'a> Widget for Crafting<'a> { Text::new(&self.localized_strings.get("hud.crafting")) .mid_top_with_margin_on(ids.window_frame, 9.0) .font_id(self.fonts.cyri.conrod_id) - .font_size(self.fonts.cyri.scale(22)) + .font_size(self.fonts.cyri.scale(20)) .color(TEXT_COLOR) .set(ids.title_main, ui); diff --git a/voxygen/src/hud/group.rs b/voxygen/src/hud/group.rs new file mode 100644 index 0000000000..26060b3084 --- /dev/null +++ b/voxygen/src/hud/group.rs @@ -0,0 +1,685 @@ +use super::{ + img_ids::Imgs, Show, BLACK, ERROR_COLOR, GROUP_COLOR, HP_COLOR, KILL_COLOR, LOW_HP_COLOR, + MANA_COLOR, TEXT_COLOR, TEXT_COLOR_GREY, UI_HIGHLIGHT_0, UI_MAIN, +}; + +use crate::{ + i18n::VoxygenLocalization, settings::Settings, ui::fonts::ConrodVoxygenFonts, + window::GameInput, GlobalState, +}; +use client::{self, Client}; +use common::{ + comp::{group::Role, Stats}, + sync::{Uid, WorldSyncExt}, +}; +use conrod_core::{ + color, + position::{Place, Relative}, + widget::{self, Button, Image, Rectangle, Scrollbar, Text}, + widget_ids, Color, Colorable, Labelable, Positionable, Sizeable, Widget, WidgetCommon, +}; +use specs::{saveload::MarkerAllocator, WorldExt}; + +widget_ids! { + pub struct Ids { + group_button, + bg, + title, + title_bg, + btn_bg, + btn_friend, + btn_leader, + btn_link, + btn_kick, + btn_leave, + scroll_area, + scrollbar, + members[], + bubble_frame, + btn_accept, + btn_decline, + member_panels_bg[], + member_panels_frame[], + member_panels_txt_bg[], + member_panels_txt[], + member_health[], + member_stam[], + dead_txt[], + health_txt[], + timeout_bg, + timeout, + } +} + +pub struct State { + ids: Ids, + // Selected group member + selected_member: Option, +} + +#[derive(WidgetCommon)] +pub struct Group<'a> { + show: &'a mut Show, + client: &'a Client, + settings: &'a Settings, + imgs: &'a Imgs, + fonts: &'a ConrodVoxygenFonts, + localized_strings: &'a std::sync::Arc, + pulse: f32, + global_state: &'a GlobalState, + + #[conrod(common_builder)] + common: widget::CommonBuilder, +} + +impl<'a> Group<'a> { + #[allow(clippy::too_many_arguments)] // TODO: Pending review in #587 + pub fn new( + show: &'a mut Show, + client: &'a Client, + settings: &'a Settings, + imgs: &'a Imgs, + fonts: &'a ConrodVoxygenFonts, + localized_strings: &'a std::sync::Arc, + pulse: f32, + global_state: &'a GlobalState, + ) -> Self { + Self { + show, + client, + settings, + imgs, + fonts, + localized_strings, + pulse, + global_state, + common: widget::CommonBuilder::default(), + } + } +} + +pub enum Event { + Accept, + Decline, + Kick(Uid), + LeaveGroup, + AssignLeader(Uid), +} + +impl<'a> Widget for Group<'a> { + type Event = Vec; + type State = State; + type Style = (); + + fn init_state(&self, id_gen: widget::id::Generator) -> Self::State { + Self::State { + ids: Ids::new(id_gen), + selected_member: None, + } + } + + fn style(&self) -> Self::Style {} + + //TODO: Disband groups when there's only one member in them + //TODO: Always send health, energy, level and position of group members to the + // client + #[allow(clippy::unused_unit)] // TODO: Pending review in #587 + #[allow(clippy::blocks_in_if_conditions)] // TODO: Pending review in #587 + fn update(self, args: widget::UpdateArgs) -> Self::Event { + let widget::UpdateArgs { state, ui, .. } = args; + + let mut events = Vec::new(); + + // Don't show pets + let group_members = self + .client + .group_members() + .iter() + .filter_map(|(u, r)| match r { + Role::Member => Some(u), + Role::Pet => None, + }) + .collect::>(); + // Not considered in group for ui purposes if it is just pets + let in_group = !group_members.is_empty(); + if !in_group { + self.show.group_menu = false; + self.show.group = false; + } + + // Helper + let uid_to_name_text = |uid, client: &Client| match client.player_list.get(&uid) { + Some(player_info) => player_info + .character + .as_ref() + .map_or_else(|| format!("Player<{}>", uid), |c| c.name.clone()), + None => client + .state() + .ecs() + .entity_from_uid(uid.0) + .and_then(|entity| { + client + .state() + .ecs() + .read_storage::() + .get(entity) + .map(|stats| stats.name.clone()) + }) + .unwrap_or_else(|| format!("Npc<{}>", uid)), + }; + + let open_invite = self.client.group_invite(); + + let my_uid = self.client.uid(); + + // TODO show something to the player when they click on the group button while + // they are not in a group so that it doesn't look like the button is + // broken + if self.show.group_menu || open_invite.is_some() { + // Frame + Rectangle::fill_with([220.0, 140.0], color::Color::Rgba(0.0, 0.0, 0.0, 0.8)) + .bottom_left_with_margins_on(ui.window, 108.0, 490.0) + .crop_kids() + .set(state.ids.bg, ui); + } + if let Some((_, timeout_start, timeout_dur)) = open_invite { + // Group Menu button + Button::image(self.imgs.group_icon) + .w_h(49.0, 26.0) + .bottom_left_with_margins_on(ui.window, 10.0, 490.0) + .set(state.ids.group_button, ui); + // Show timeout bar + let timeout_progress = + 1.0 - timeout_start.elapsed().as_secs_f32() / timeout_dur.as_secs_f32(); + Image::new(self.imgs.progress_frame) + .w_h(100.0, 10.0) + .middle_of(state.ids.bg) + .color(Some(UI_MAIN)) + .set(state.ids.timeout_bg, ui); + Image::new(self.imgs.progress) + .w_h(98.0 * timeout_progress as f64, 8.0) + .top_left_with_margins_on(state.ids.timeout_bg, 1.0, 1.0) + .color(Some(UI_HIGHLIGHT_0)) + .set(state.ids.timeout, ui); + } + // Buttons + if let Some((group_name, leader)) = self.client.group_info().filter(|_| in_group) { + // Group Menu Button + if Button::image(if self.show.group_menu { + self.imgs.group_icon_press + } else { + self.imgs.group_icon + }) + .w_h(49.0, 26.0) + .bottom_left_with_margins_on(ui.window, 10.0, 490.0) + .hover_image(self.imgs.group_icon_hover) + .press_image(self.imgs.group_icon_press) + .set(state.ids.group_button, ui) + .was_clicked() + { + self.show.group_menu = !self.show.group_menu; + }; + Text::new(&group_name) + .up_from(state.ids.group_button, 5.0) + .font_size(14) + .font_id(self.fonts.cyri.conrod_id) + .color(BLACK) + .set(state.ids.title_bg, ui); + Text::new(&group_name) + .bottom_right_with_margins_on(state.ids.title_bg, 1.0, 1.0) + .font_size(14) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(state.ids.title, ui); + // Member panels + let group_size = group_members.len(); + if state.ids.member_panels_bg.len() < group_size { + state.update(|s| { + s.ids + .member_panels_bg + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + if state.ids.member_health.len() < group_size { + state.update(|s| { + s.ids + .member_health + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + if state.ids.member_stam.len() < group_size { + state.update(|s| { + s.ids + .member_stam + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + if state.ids.member_panels_frame.len() < group_size { + state.update(|s| { + s.ids + .member_panels_frame + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + if state.ids.member_panels_txt.len() < group_size { + state.update(|s| { + s.ids + .member_panels_txt + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + if state.ids.dead_txt.len() < group_size { + state.update(|s| { + s.ids + .dead_txt + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + if state.ids.health_txt.len() < group_size { + state.update(|s| { + s.ids + .health_txt + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + if state.ids.member_panels_txt_bg.len() < group_size { + state.update(|s| { + s.ids + .member_panels_txt_bg + .resize(group_size, &mut ui.widget_id_generator()) + }) + }; + + let client_state = self.client.state(); + let stats = client_state.ecs().read_storage::(); + let energy = client_state.ecs().read_storage::(); + let uid_allocator = client_state + .ecs() + .read_resource::(); + + for (i, &uid) in group_members.iter().copied().enumerate() { + self.show.group = true; + let entity = uid_allocator.retrieve_entity_internal(uid.into()); + let stats = entity.and_then(|entity| stats.get(entity)); + let energy = entity.and_then(|entity| energy.get(entity)); + if let Some(stats) = stats { + let char_name = stats.name.to_string(); + let health_perc = stats.health.current() as f64 / stats.health.maximum() as f64; + + // change panel positions when debug info is shown + let offset = if self.global_state.settings.gameplay.toggle_debug { + 290.0 + } else { + 110.0 + }; + let back = if i == 0 { + Image::new(self.imgs.member_bg) + .top_left_with_margins_on(ui.window, offset, 20.0) + } else { + Image::new(self.imgs.member_bg) + .down_from(state.ids.member_panels_bg[i - 1], 40.0) + }; + let hp_ani = (self.pulse * 4.0/* speed factor */).cos() * 0.5 + 0.8; //Animation timer + let crit_hp_color: Color = Color::Rgba(0.79, 0.19, 0.17, hp_ani); + let health_col = match (health_perc * 100.0) as u8 { + 0..=20 => crit_hp_color, + 21..=40 => LOW_HP_COLOR, + _ => HP_COLOR, + }; + let is_leader = uid == leader; + // Don't show panel for the player! + // Panel BG + back.w_h(152.0, 36.0) + .color(if is_leader { + Some(ERROR_COLOR) + } else { + Some(TEXT_COLOR) + }) + .set(state.ids.member_panels_bg[i], ui); + // Health + Image::new(self.imgs.bar_content) + .w_h(148.0 * health_perc, 22.0) + .color(Some(health_col)) + .top_left_with_margins_on(state.ids.member_panels_bg[i], 2.0, 2.0) + .set(state.ids.member_health[i], ui); + if stats.is_dead { + // Death Text + Text::new(&self.localized_strings.get("hud.group.dead")) + .mid_top_with_margin_on(state.ids.member_panels_bg[i], 1.0) + .font_size(20) + .font_id(self.fonts.cyri.conrod_id) + .color(KILL_COLOR) + .set(state.ids.dead_txt[i], ui); + } else { + // Health Text + let txt = format!( + "{}/{}", + stats.health.current() / 10 as u32, + stats.health.maximum() / 10 as u32, + ); + // Change font size depending on health amount + let font_size = match stats.health.maximum() { + 0..=999 => 14, + 1000..=9999 => 13, + 10000..=99999 => 12, + _ => 11, + }; + // Change text offset depending on health amount + let txt_offset = match stats.health.maximum() { + 0..=999 => 4.0, + 1000..=9999 => 4.5, + 10000..=99999 => 5.0, + _ => 5.5, + }; + Text::new(&txt) + .mid_top_with_margin_on(state.ids.member_panels_bg[i], txt_offset) + .font_size(font_size) + .font_id(self.fonts.cyri.conrod_id) + .color(Color::Rgba(1.0, 1.0, 1.0, 0.5)) + .set(state.ids.health_txt[i], ui); + }; + // Panel Frame + Image::new(self.imgs.member_frame) + .w_h(152.0, 36.0) + .middle_of(state.ids.member_panels_bg[i]) + .color(Some(UI_HIGHLIGHT_0)) + .set(state.ids.member_panels_frame[i], ui); + // Panel Text + Text::new(&char_name) + .top_left_with_margins_on(state.ids.member_panels_frame[i], -22.0, 0.0) + .font_size(20) + .font_id(self.fonts.cyri.conrod_id) + .color(BLACK) + .w(300.0) // limit name length display + .set(state.ids.member_panels_txt_bg[i], ui); + Text::new(&char_name) + .bottom_left_with_margins_on(state.ids.member_panels_txt_bg[i], 2.0, 2.0) + .font_size(20) + .font_id(self.fonts.cyri.conrod_id) + .color(if is_leader { ERROR_COLOR } else { GROUP_COLOR }) + .w(300.0) // limit name length display + .set(state.ids.member_panels_txt[i], ui); + if let Some(energy) = energy { + let stam_perc = energy.current() as f64 / energy.maximum() as f64; + // Stamina + Image::new(self.imgs.bar_content) + .w_h(100.0 * stam_perc, 8.0) + .color(Some(MANA_COLOR)) + .top_left_with_margins_on(state.ids.member_panels_bg[i], 26.0, 2.0) + .set(state.ids.member_stam[i], ui); + } + } else { + // Values N.A. + if let Some(stats) = stats { + Text::new(&stats.name.to_string()) + .top_left_with_margins_on(state.ids.member_panels_frame[i], -22.0, 0.0) + .font_size(20) + .font_id(self.fonts.cyri.conrod_id) + .color(GROUP_COLOR) + .set(state.ids.member_panels_txt[i], ui); + }; + let offset = if self.global_state.settings.gameplay.toggle_debug { + 210.0 + } else { + 110.0 + }; + let back = if i == 0 { + Image::new(self.imgs.member_bg) + .top_left_with_margins_on(ui.window, offset, 20.0) + } else { + Image::new(self.imgs.member_bg) + .down_from(state.ids.member_panels_bg[i - 1], 40.0) + }; + back.w_h(152.0, 36.0) + .color(Some(TEXT_COLOR)) + .set(state.ids.member_panels_bg[i], ui); + // Panel Frame + Image::new(self.imgs.member_frame) + .w_h(152.0, 36.0) + .middle_of(state.ids.member_panels_bg[i]) + .color(Some(UI_HIGHLIGHT_0)) + .set(state.ids.member_panels_frame[i], ui); + // Panel Text + Text::new(&self.localized_strings.get("hud.group.out_of_range")) + .mid_top_with_margin_on(state.ids.member_panels_bg[i], 3.0) + .font_size(16) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(state.ids.dead_txt[i], ui); + } + } + + if self.show.group_menu { + let selected = state.selected_member; + if Button::image(self.imgs.button) // Change button behaviour and style when the friendslist is working + .w_h(90.0, 22.0) + .top_right_with_margins_on(state.ids.bg, 5.0, 5.0) + .hover_image(self.imgs.button) + .press_image(self.imgs.button) + .label_color(TEXT_COLOR_GREY) + .image_color(TEXT_COLOR_GREY) + .label(&self.localized_strings.get("hud.group.add_friend")) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(10)) + .set(state.ids.btn_friend, ui) + .was_clicked() + {}; + if Button::image(self.imgs.button) + .w_h(90.0, 22.0) + .bottom_right_with_margins_on(state.ids.bg, 5.0, 5.0) + .hover_image(self.imgs.button_hover) + .press_image(self.imgs.button_press) + .label(&self.localized_strings.get("hud.group.leave")) + .label_color(TEXT_COLOR) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(10)) + .set(state.ids.btn_leave, ui) + .was_clicked() + { + self.show.group_menu = false; + self.show.group = !self.show.group; + events.push(Event::LeaveGroup); + }; + // Group leader functions + if my_uid == Some(leader) { + if Button::image(self.imgs.button) + .w_h(90.0, 22.0) + .mid_bottom_with_margin_on(state.ids.btn_friend, -27.0) + .hover_image(self.imgs.button_hover) + .press_image(self.imgs.button_press) + .label(&self.localized_strings.get("hud.group.assign_leader")) + .label_color(if state.selected_member.is_some() { + TEXT_COLOR + } else { + TEXT_COLOR_GREY + }) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(10)) + .set(state.ids.btn_leader, ui) + .was_clicked() + { + if let Some(uid) = selected { + events.push(Event::AssignLeader(uid)); + state.update(|s| { + s.selected_member = None; + }); + } + }; + if Button::image(self.imgs.button) + .w_h(90.0, 22.0) + .mid_bottom_with_margin_on(state.ids.btn_leader, -27.0) + .hover_image(self.imgs.button) + .press_image(self.imgs.button) + .label(&self.localized_strings.get("hud.group.link_group")) + .hover_image(self.imgs.button) + .press_image(self.imgs.button) + .label_color(TEXT_COLOR_GREY) + .image_color(TEXT_COLOR_GREY) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(10)) + .set(state.ids.btn_link, ui) + .was_clicked() + {}; + if Button::image(self.imgs.button) + .w_h(90.0, 22.0) + .mid_bottom_with_margin_on(state.ids.btn_link, -27.0) + .down_from(state.ids.btn_link, 5.0) + .hover_image(self.imgs.button_hover) + .press_image(self.imgs.button_press) + .label(&self.localized_strings.get("hud.group.kick")) + .label_color(if state.selected_member.is_some() { + TEXT_COLOR + } else { + TEXT_COLOR_GREY + }) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(10)) + .set(state.ids.btn_kick, ui) + .was_clicked() + { + if let Some(uid) = selected { + events.push(Event::Kick(uid)); + state.update(|s| { + s.selected_member = None; + }); + } + }; + } + // Group Members, only character names, cut long names when they exceed the + // button size + let group_size = group_members.len(); + if state.ids.members.len() < group_size { + state.update(|s| { + s.ids + .members + .resize(group_size, &mut ui.widget_id_generator()) + }) + } + // Scrollable area for group member names + Rectangle::fill_with([110.0, 135.0], color::TRANSPARENT) + .top_left_with_margins_on(state.ids.bg, 5.0, 5.0) + .crop_kids() + .scroll_kids_vertically() + .set(state.ids.scroll_area, ui); + Scrollbar::y_axis(state.ids.scroll_area) + .thickness(5.0) + .rgba(0.33, 0.33, 0.33, 1.0) + .set(state.ids.scrollbar, ui); + // List member names + for (i, &uid) in group_members.iter().copied().enumerate() { + let selected = state.selected_member.map_or(false, |u| u == uid); + let char_name = uid_to_name_text(uid, &self.client); + // TODO: Do something special visually if uid == leader + if Button::image(if selected { + self.imgs.selection + } else { + self.imgs.nothing + }) + .w_h(100.0, 22.0) + .and(|w| { + if i == 0 { + w.top_left_with_margins_on(state.ids.scroll_area, 5.0, 0.0) + } else { + w.down_from(state.ids.members[i - 1], 5.0) + } + }) + .hover_image(self.imgs.selection_hover) + .press_image(self.imgs.selection_press) + .crop_kids() + .label_x(Relative::Place(Place::Start(Some(4.0)))) + .label(&char_name) + .label_color(if uid == leader { + ERROR_COLOR + } else { + TEXT_COLOR + }) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(12)) + .set(state.ids.members[i], ui) + .was_clicked() + { + // Do nothing when clicking yourself + if Some(uid) != my_uid { + // Select the group member + state.update(|s| { + s.selected_member = if selected { None } else { Some(uid) } + }); + } + }; + } + // Maximum of 6 Players/Npcs per Group + // Player pets count as group members, too. They are not counted + // into the maximum group size. + } + } + if let Some((invite_uid, _, _)) = open_invite { + self.show.group = true; // Auto open group menu + // TODO: add group name here too + // Invite text + + let name = uid_to_name_text(invite_uid, &self.client); + let invite_text = self + .localized_strings + .get("hud.group.invite_to_join") + .replace("{name}", &name); + Text::new(&invite_text) + .mid_top_with_margin_on(state.ids.bg, 5.0) + .font_size(12) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .w(165.0) // Text stays within frame + .set(state.ids.title, ui); + // Accept Button + let accept_key = self + .settings + .controls + .get_binding(GameInput::AcceptGroupInvite) + .map_or_else(|| "".into(), |key| key.to_string()); + if Button::image(self.imgs.button) + .w_h(90.0, 22.0) + .bottom_left_with_margins_on(state.ids.bg, 15.0, 15.0) + .hover_image(self.imgs.button_hover) + .press_image(self.imgs.button_press) + .label(&format!( + "[{}] {}", + &accept_key, + &self.localized_strings.get("common.accept") + )) + .label_color(TEXT_COLOR) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(12)) + .set(state.ids.btn_accept, ui) + .was_clicked() + { + events.push(Event::Accept); + self.show.group_menu = true; + }; + // Decline button + let decline_key = self + .settings + .controls + .get_binding(GameInput::DeclineGroupInvite) + .map_or_else(|| "".into(), |key| key.to_string()); + if Button::image(self.imgs.button) + .w_h(90.0, 22.0) + .bottom_right_with_margins_on(state.ids.bg, 15.0, 15.0) + .hover_image(self.imgs.button_hover) + .press_image(self.imgs.button_press) + .label(&format!( + "[{}] {}", + &decline_key, + &self.localized_strings.get("common.decline") + )) + .label_color(TEXT_COLOR) + .label_font_id(self.fonts.cyri.conrod_id) + .label_font_size(self.fonts.cyri.scale(12)) + .set(state.ids.btn_decline, ui) + .was_clicked() + { + events.push(Event::Decline); + }; + } + + events + } +} diff --git a/voxygen/src/hud/img_ids.rs b/voxygen/src/hud/img_ids.rs index 240860dd6e..04f60f0b3a 100644 --- a/voxygen/src/hud/img_ids.rs +++ b/voxygen/src/hud/img_ids.rs @@ -59,14 +59,19 @@ image_ids! { selection_press: "voxygen.element.frames.selection_press", // Social Window - social_button: "voxygen.element.buttons.social_tab", - social_button_pressed: "voxygen.element.buttons.social_tab_pressed", - social_button_hover: "voxygen.element.buttons.social_tab_hover", - social_button_press: "voxygen.element.buttons.social_tab_press", - social_frame: "voxygen.element.frames.social_frame", + social_frame_on: "voxygen.element.misc_bg.social_frame", + social_bg_on: "voxygen.element.misc_bg.social_bg", + social_frame_friends: "voxygen.element.misc_bg.social_frame", + social_bg_friends: "voxygen.element.misc_bg.social_bg", + social_frame_fact: "voxygen.element.misc_bg.social_frame", + social_bg_fact: "voxygen.element.misc_bg.social_bg", + social_tab_act: "voxygen.element.buttons.social_tab_active", + social_tab_online: "voxygen.element.misc_bg.social_tab_online", + social_tab_inact: "voxygen.element.buttons.social_tab_inactive", + social_tab_inact_hover: "voxygen.element.buttons.social_tab_inactive", + social_tab_inact_press: "voxygen.element.buttons.social_tab_inactive", // Crafting Window - crafting_window: "voxygen.element.misc_bg.crafting", crafting_frame: "voxygen.element.misc_bg.crafting_frame", crafting_icon_bordered: "voxygen.element.icons.anvil", @@ -74,6 +79,9 @@ image_ids! { crafting_icon_hover: "voxygen.element.buttons.anvil_hover", crafting_icon_press: "voxygen.element.buttons.anvil_press", + // Group Window + member_frame: "voxygen.element.frames.group_member_frame", + member_bg: "voxygen.element.frames.group_member_bg", // Chat-Arrows chat_arrow: "voxygen.element.buttons.arrow_down", @@ -94,7 +102,6 @@ image_ids! { slider_indicator_small: "voxygen.element.slider.indicator_round", // Buttons - settings: "voxygen.element.buttons.settings", settings_hover: "voxygen.element.buttons.settings_hover", settings_press: "voxygen.element.buttons.settings_press", @@ -111,6 +118,10 @@ image_ids! { spellbook_hover: "voxygen.element.buttons.spellbook_hover", spellbook_press: "voxygen.element.buttons.spellbook_press", + group_icon: "voxygen.element.buttons.group", + group_icon_hover: "voxygen.element.buttons.group_hover", + group_icon_press: "voxygen.element.buttons.group_press", + // Skill Icons twohsword_m1: "voxygen.element.icons.2hsword_m1", twohsword_m2: "voxygen.element.icons.2hsword_m2", diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs index 6cad71c471..33d400735e 100644 --- a/voxygen/src/hud/mod.rs +++ b/voxygen/src/hud/mod.rs @@ -3,6 +3,7 @@ mod buttons; mod chat; mod crafting; mod esc_menu; +mod group; mod hotbar; mod img_ids; mod item_imgs; @@ -30,6 +31,7 @@ use chat::Chat; use chrono::NaiveTime; use crafting::Crafting; use esc_menu::EscMenu; +use group::Group; use img_ids::Imgs; use item_imgs::ItemImgs; use map::Map; @@ -69,7 +71,7 @@ const TEXT_COLOR: Color = Color::Rgba(1.0, 1.0, 1.0, 1.0); const TEXT_GRAY_COLOR: Color = Color::Rgba(0.5, 0.5, 0.5, 1.0); const TEXT_DULL_RED_COLOR: Color = Color::Rgba(0.56, 0.2, 0.2, 1.0); const TEXT_BG: Color = Color::Rgba(0.0, 0.0, 0.0, 1.0); -//const TEXT_COLOR_GREY: Color = Color::Rgba(1.0, 1.0, 1.0, 0.5); +const TEXT_COLOR_GREY: Color = Color::Rgba(1.0, 1.0, 1.0, 0.5); const MENU_BG: Color = Color::Rgba(0.0, 0.0, 0.0, 0.4); //const TEXT_COLOR_2: Color = Color::Rgba(0.0, 0.0, 0.0, 1.0); const TEXT_COLOR_3: Color = Color::Rgba(1.0, 1.0, 1.0, 0.1); @@ -80,6 +82,7 @@ const HP_COLOR: Color = Color::Rgba(0.33, 0.63, 0.0, 1.0); const LOW_HP_COLOR: Color = Color::Rgba(0.93, 0.59, 0.03, 1.0); const CRITICAL_HP_COLOR: Color = Color::Rgba(0.79, 0.19, 0.17, 1.0); const MANA_COLOR: Color = Color::Rgba(0.29, 0.62, 0.75, 0.9); +//const TRANSPARENT: Color = Color::Rgba(0.0, 0.0, 0.0, 0.0); //const FOCUS_COLOR: Color = Color::Rgba(1.0, 0.56, 0.04, 1.0); //const RAGE_COLOR: Color = Color::Rgba(0.5, 0.04, 0.13, 1.0); @@ -109,12 +112,18 @@ const WORLD_COLOR: Color = Color::Rgba(0.95, 1.0, 0.95, 1.0); /// Color for collected loot messages const LOOT_COLOR: Color = Color::Rgba(0.69, 0.57, 1.0, 1.0); +//Nametags +const GROUP_MEMBER: Color = Color::Rgba(0.47, 0.84, 1.0, 1.0); +const DEFAULT_NPC: Color = Color::Rgba(1.0, 1.0, 1.0, 1.0); + // UI Color-Theme const UI_MAIN: Color = Color::Rgba(0.61, 0.70, 0.70, 1.0); // Greenish Blue //const UI_MAIN: Color = Color::Rgba(0.1, 0.1, 0.1, 0.97); // Dark const UI_HIGHLIGHT_0: Color = Color::Rgba(0.79, 1.09, 1.09, 1.0); //const UI_DARK_0: Color = Color::Rgba(0.25, 0.37, 0.37, 1.0); +/// Distance at which nametags are visible for group members +const NAMETAG_GROUP_RANGE: f32 = 300.0; /// Distance at which nametags are visible const NAMETAG_RANGE: f32 = 40.0; /// Time nametags stay visible after doing damage even if they are out of range @@ -208,6 +217,7 @@ widget_ids! { social_window, crafting_window, settings_window, + group_window, // Free look indicator free_look_txt, @@ -242,6 +252,8 @@ pub struct DebugInfo { pub struct HudInfo { pub is_aiming: bool, pub is_first_person: bool, + pub target_entity: Option, + pub selected_entity: Option<(specs::Entity, std::time::Instant)>, } pub enum Event { @@ -297,6 +309,12 @@ pub enum Event { ChangeAutoWalkBehavior(PressBehavior), ChangeStopAutoWalkOnInput(bool), CraftRecipe(String), + InviteMember(common::sync::Uid), + AcceptInvite, + DeclineInvite, + KickMember(common::sync::Uid), + LeaveGroup, + AssignLeader(common::sync::Uid), } // TODO: Are these the possible layouts we want? @@ -352,6 +370,8 @@ pub struct Show { bag: bool, social: bool, spell: bool, + group: bool, + group_menu: bool, esc_menu: bool, open_windows: Windows, map: bool, @@ -389,7 +409,6 @@ impl Show { fn social(&mut self, open: bool) { if !self.esc_menu { self.social = open; - self.crafting = false; self.spell = false; self.want_grab = !open; } @@ -489,7 +508,7 @@ impl Show { } fn toggle_social(&mut self) { - self.social = !self.social; + self.social(!self.social); self.spell = false; } @@ -598,6 +617,8 @@ impl Hud { ui: true, social: false, spell: false, + group: false, + group_menu: false, mini_map: true, settings_tab: SettingsTab::Interface, social_tab: SocialTab::Online, @@ -1032,7 +1053,7 @@ impl Hud { } // Render overhead name tags and health bars - for (pos, name, stats, energy, height_offset, hpfl, uid) in ( + for (pos, name, stats, energy, height_offset, hpfl, uid, in_group) in ( &entities, &pos, interpolated.maybe(), @@ -1045,11 +1066,34 @@ impl Hud { &uids, ) .join() - .filter(|(entity, _, _, stats, _, _, _, _, _, _)| *entity != me && !stats.is_dead) - // Don't show outside a certain range - .filter(|(_, pos, _, _, _, _, _, _, hpfl, _)| { - pos.0.distance_squared(player_pos) - < (if hpfl + .map(|(a, b, c, d, e, f, g, h, i, uid)| { + ( + a, + b, + c, + d, + e, + f, + g, + h, + i, + uid, + client.group_members().contains_key(uid), + ) + }) + .filter(|(entity, pos, _, stats, _, _, _, _, hpfl, _, in_group)| { + *entity != me && !stats.is_dead + && (stats.health.current() != stats.health.maximum() + || info.target_entity.map_or(false, |e| e == *entity) + || info.selected_entity.map_or(false, |s| s.0 == *entity) + || *in_group + ) + // Don't show outside a certain range + && pos.0.distance_squared(player_pos) + < (if *in_group + { + NAMETAG_GROUP_RANGE + } else if hpfl .time_since_last_dmg_by_me .map_or(false, |t| t < NAMETAG_DMG_TIME) { @@ -1059,25 +1103,40 @@ impl Hud { }) .powi(2) }) - .map(|(_, pos, interpolated, stats, energy, player, scale, body, hpfl, uid)| { - // TODO: This is temporary - // If the player used the default character name display their name instead - let name = if stats.name == "Character Name" { - player.map_or(&stats.name, |p| &p.alias) - } else { - &stats.name - }; - ( - interpolated.map_or(pos.0, |i| i.pos), - name, + .map( + |( + _, + pos, + interpolated, stats, energy, - // TODO: when body.height() is more accurate remove the 2.0 - body.height() * 2.0 * scale.map_or(1.0, |s| s.0), + player, + scale, + body, hpfl, uid, - ) - }) + in_group, + )| { + // TODO: This is temporary + // If the player used the default character name display their name instead + let name = if stats.name == "Character Name" { + player.map_or(&stats.name, |p| &p.alias) + } else { + &stats.name + }; + ( + interpolated.map_or(pos.0, |i| i.pos), + name, + stats, + energy, + // TODO: when body.height() is more accurate remove the 2.0 + body.height() * 2.0 * scale.map_or(1.0, |s| s.0), + hpfl, + uid, + in_group, + ) + }, + ) { let bubble = self.speech_bubbles.get(uid); @@ -1094,6 +1153,7 @@ impl Hud { stats, energy, own_level, + in_group, &global_state.settings.gameplay, self.pulse, &self.voxygen_i18n, @@ -1863,23 +1923,56 @@ impl Hud { // Social Window if self.show.social { - for event in Social::new( - &self.show, - client, - &self.imgs, - &self.fonts, - &self.voxygen_i18n, - ) - .set(self.ids.social_window, ui_widgets) - { - match event { - social::Event::Close => self.show.social(false), - social::Event::ChangeSocialTab(social_tab) => { - self.show.open_social_tab(social_tab) - }, + let ecs = client.state().ecs(); + let _stats = ecs.read_storage::(); + let me = client.entity(); + if let Some(_stats) = stats.get(me) { + for event in Social::new( + &self.show, + client, + &self.imgs, + &self.fonts, + &self.voxygen_i18n, + info.selected_entity, + &self.rot_imgs, + tooltip_manager, + ) + .set(self.ids.social_window, ui_widgets) + { + match event { + social::Event::Close => { + self.show.social(false); + self.force_ungrab = true; + }, + social::Event::ChangeSocialTab(social_tab) => { + self.show.open_social_tab(social_tab) + }, + social::Event::Invite(uid) => events.push(Event::InviteMember(uid)), + } } } } + // Group Window + for event in Group::new( + &mut self.show, + client, + &global_state.settings, + &self.imgs, + &self.fonts, + &self.voxygen_i18n, + self.pulse, + &global_state, + ) + .set(self.ids.group_window, ui_widgets) + { + match event { + group::Event::Accept => events.push(Event::AcceptInvite), + group::Event::Decline => events.push(Event::DeclineInvite), + group::Event::Kick(uid) => events.push(Event::KickMember(uid)), + group::Event::LeaveGroup => events.push(Event::LeaveGroup), + group::Event::AssignLeader(uid) => events.push(Event::AssignLeader(uid)), + } + } // Spellbook if self.show.spell { diff --git a/voxygen/src/hud/overhead.rs b/voxygen/src/hud/overhead.rs index ad551d7281..b14f39c213 100644 --- a/voxygen/src/hud/overhead.rs +++ b/voxygen/src/hud/overhead.rs @@ -1,6 +1,6 @@ use super::{ - img_ids::Imgs, FACTION_COLOR, GROUP_COLOR, HP_COLOR, LOW_HP_COLOR, MANA_COLOR, REGION_COLOR, - SAY_COLOR, TELL_COLOR, TEXT_BG, TEXT_COLOR, + img_ids::Imgs, DEFAULT_NPC, FACTION_COLOR, GROUP_COLOR, GROUP_MEMBER, HP_COLOR, LOW_HP_COLOR, + MANA_COLOR, REGION_COLOR, SAY_COLOR, TELL_COLOR, TEXT_BG, TEXT_COLOR, }; use crate::{ i18n::VoxygenLocalization, @@ -42,6 +42,7 @@ widget_ids! { level_skull, health_bar, health_bar_bg, + health_txt, mana_bar, health_bar_fg, } @@ -56,11 +57,13 @@ pub struct Overhead<'a> { stats: &'a Stats, energy: Option<&'a Energy>, own_level: u32, + in_group: bool, settings: &'a GameplaySettings, pulse: f32, voxygen_i18n: &'a std::sync::Arc, imgs: &'a Imgs, fonts: &'a ConrodVoxygenFonts, + #[conrod(common_builder)] common: widget::CommonBuilder, } @@ -73,6 +76,7 @@ impl<'a> Overhead<'a> { stats: &'a Stats, energy: Option<&'a Energy>, own_level: u32, + in_group: bool, settings: &'a GameplaySettings, pulse: f32, voxygen_i18n: &'a std::sync::Arc, @@ -85,6 +89,7 @@ impl<'a> Overhead<'a> { stats, energy, own_level, + in_group, settings, pulse, voxygen_i18n, @@ -104,13 +109,20 @@ impl<'a> Ingameable for Overhead<'a> { // Number of conrod primitives contained in the overhead display. TODO maybe // this could be done automatically? // - 2 Text::new for name + // If HP Info is shown + 6 // - 1 for level: either Text or Image // - 4 for HP + mana + fg + bg - // If there's a speech bubble - // - 2 Text::new for speech bubble + // - 1 for HP Text + // If there's a speech bubble + 13 + // - 2 Text::new for speec8 bubble // - 1 Image::new for icon // - 10 Image::new for speech bubble (9-slice + tail) - 7 + if self.bubble.is_some() { 13 } else { 0 } + 2 + if self.bubble.is_some() { 13 } else { 0 } + + if (self.stats.health.current() as f64 / self.stats.health.maximum() as f64) < 1.0 { + 6 + } else { + 0 + } } } @@ -134,19 +146,35 @@ impl<'a> Widget for Overhead<'a> { const BARSIZE: f64 = 2.0; const MANA_BAR_HEIGHT: f64 = BARSIZE * 1.5; const MANA_BAR_Y: f64 = MANA_BAR_HEIGHT / 2.0; - + let hp_percentage = + self.stats.health.current() as f64 / self.stats.health.maximum() as f64 * 100.0; + let level_comp = self.stats.level.level() as i64 - self.own_level as i64; + let name_y = if hp_percentage.abs() > 99.9 { + MANA_BAR_Y + 20.0 + } else if level_comp > 9 { + MANA_BAR_Y + 38.0 + } else { + MANA_BAR_Y + 32.0 + }; + let font_size = if hp_percentage.abs() > 99.9 { 24 } else { 20 }; // Name Text::new(&self.name) .font_id(self.fonts.cyri.conrod_id) - .font_size(30) + .font_size(font_size) .color(Color::Rgba(0.0, 0.0, 0.0, 1.0)) - .x_y(-1.0, MANA_BAR_Y + 48.0) + .x_y(-1.0, name_y) + .parent(id) .set(state.ids.name_bg, ui); Text::new(&self.name) .font_id(self.fonts.cyri.conrod_id) - .font_size(30) - .color(Color::Rgba(0.61, 0.61, 0.89, 1.0)) - .x_y(0.0, MANA_BAR_Y + 50.0) + .font_size(font_size) + .color(if self.in_group { + GROUP_MEMBER + } else { + DEFAULT_NPC + }) + .x_y(0.0, name_y + 1.0) + .parent(id) .set(state.ids.name, ui); // Speech bubble @@ -160,7 +188,7 @@ impl<'a> Widget for Overhead<'a> { .color(text_color) .font_id(self.fonts.cyri.conrod_id) .font_size(18) - .up_from(state.ids.name, 20.0) + .up_from(state.ids.name, 26.0) .x_align_to(state.ids.name, Align::Middle) .parent(id); @@ -295,101 +323,121 @@ impl<'a> Widget for Overhead<'a> { .set(state.ids.speech_bubble_icon, ui); } - let hp_percentage = - self.stats.health.current() as f64 / self.stats.health.maximum() as f64 * 100.0; - let hp_ani = (self.pulse * 4.0/* speed factor */).cos() * 0.5 + 1.0; //Animation timer - let crit_hp_color: Color = Color::Rgba(0.79, 0.19, 0.17, hp_ani); + if hp_percentage < 100.0 { + // Show HP Bar + let hp_percentage = + self.stats.health.current() as f64 / self.stats.health.maximum() as f64 * 100.0; + let hp_ani = (self.pulse * 4.0/* speed factor */).cos() * 0.5 + 1.0; //Animation timer + let crit_hp_color: Color = Color::Rgba(0.79, 0.19, 0.17, hp_ani); - // Background - Image::new(self.imgs.enemy_health_bg) + // Background + Image::new(self.imgs.enemy_health_bg) .w_h(84.0 * BARSIZE, 10.0 * BARSIZE) .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5) .color(Some(Color::Rgba(0.1, 0.1, 0.1, 0.8))) .parent(id) .set(state.ids.health_bar_bg, ui); - // % HP Filling - Image::new(self.imgs.enemy_bar) - .w_h(73.0 * (hp_percentage / 100.0) * BARSIZE, 6.0 * BARSIZE) - .x_y( - (4.5 + (hp_percentage / 100.0 * 36.45 - 36.45)) * BARSIZE, - MANA_BAR_Y + 7.5, - ) - .color(Some(if hp_percentage <= 25.0 { - crit_hp_color - } else if hp_percentage <= 50.0 { - LOW_HP_COLOR - } else { - HP_COLOR - })) - .parent(id) - .set(state.ids.health_bar, ui); + // % HP Filling + Image::new(self.imgs.enemy_bar) + .w_h(73.0 * (hp_percentage / 100.0) * BARSIZE, 6.0 * BARSIZE) + .x_y( + (4.5 + (hp_percentage / 100.0 * 36.45 - 36.45)) * BARSIZE, + MANA_BAR_Y + 7.5, + ) + .color(Some(if hp_percentage <= 25.0 { + crit_hp_color + } else if hp_percentage <= 50.0 { + LOW_HP_COLOR + } else { + HP_COLOR + })) + .parent(id) + .set(state.ids.health_bar, ui); + // TODO Only show health values for entities below 100% health + let mut txt = format!( + "{}/{}", + self.stats.health.current().max(1) / 10 as u32, /* Don't show 0 health for + * living entities */ + self.stats.health.maximum() / 10 as u32, + ); + if self.stats.is_dead { + txt = self.voxygen_i18n.get("hud.group.dead").to_string() + }; + Text::new(&txt) + .mid_top_with_margin_on(state.ids.health_bar_bg, 2.0) + .font_size(10) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .parent(id) + .set(state.ids.health_txt, ui); - // % Mana Filling - if let Some(energy) = self.energy { - let energy_factor = energy.current() as f64 / energy.maximum() as f64; + // % Mana Filling + if let Some(energy) = self.energy { + let energy_factor = energy.current() as f64 / energy.maximum() as f64; - Rectangle::fill_with( - [72.0 * energy_factor * BARSIZE, MANA_BAR_HEIGHT], - MANA_COLOR, - ) - .x_y( - ((3.5 + (energy_factor * 36.5)) - 36.45) * BARSIZE, - MANA_BAR_Y, //-32.0, - ) - .parent(id) - .set(state.ids.mana_bar, ui); - } + Rectangle::fill_with( + [72.0 * energy_factor * BARSIZE, MANA_BAR_HEIGHT], + MANA_COLOR, + ) + .x_y( + ((3.5 + (energy_factor * 36.5)) - 36.45) * BARSIZE, + MANA_BAR_Y, //-32.0, + ) + .parent(id) + .set(state.ids.mana_bar, ui); + } - // Foreground - Image::new(self.imgs.enemy_health) + // Foreground + Image::new(self.imgs.enemy_health) .w_h(84.0 * BARSIZE, 10.0 * BARSIZE) .x_y(0.0, MANA_BAR_Y + 6.5) //-25.5) .color(Some(Color::Rgba(1.0, 1.0, 1.0, 0.99))) .parent(id) .set(state.ids.health_bar_fg, ui); - // Level - const LOW: Color = Color::Rgba(0.54, 0.81, 0.94, 0.4); - const HIGH: Color = Color::Rgba(1.0, 0.0, 0.0, 1.0); - const EQUAL: Color = Color::Rgba(1.0, 1.0, 1.0, 1.0); - // Change visuals of the level display depending on the player level/opponent - // level - let level_comp = self.stats.level.level() as i64 - self.own_level as i64; - // + 10 level above player -> skull - // + 5-10 levels above player -> high - // -5 - +5 levels around player level -> equal - // - 5 levels below player -> low - if level_comp > 9 { - let skull_ani = ((self.pulse * 0.7/* speed factor */).cos() * 0.5 + 0.5) * 10.0; //Animation timer - Image::new(if skull_ani as i32 == 1 && rand::random::() < 0.9 { - self.imgs.skull_2 - } else { - self.imgs.skull - }) - .w_h(18.0 * BARSIZE, 18.0 * BARSIZE) - .x_y(-39.0 * BARSIZE, MANA_BAR_Y + 7.0) - .color(Some(Color::Rgba(1.0, 1.0, 1.0, 1.0))) - .parent(id) - .set(state.ids.level_skull, ui); - } else { - Text::new(&format!("{}", self.stats.level.level())) - .font_id(self.fonts.cyri.conrod_id) - .font_size(if self.stats.level.level() > 9 && level_comp < 10 { - 14 + // Level + const LOW: Color = Color::Rgba(0.54, 0.81, 0.94, 0.4); + const HIGH: Color = Color::Rgba(1.0, 0.0, 0.0, 1.0); + const EQUAL: Color = Color::Rgba(1.0, 1.0, 1.0, 1.0); + // Change visuals of the level display depending on the player level/opponent + // level + let level_comp = self.stats.level.level() as i64 - self.own_level as i64; + // + 10 level above player -> skull + // + 5-10 levels above player -> high + // -5 - +5 levels around player level -> equal + // - 5 levels below player -> low + if level_comp > 9 { + let skull_ani = ((self.pulse * 0.7/* speed factor */).cos() * 0.5 + 0.5) * 10.0; //Animation timer + Image::new(if skull_ani as i32 == 1 && rand::random::() < 0.9 { + self.imgs.skull_2 } else { - 15 + self.imgs.skull }) - .color(if level_comp > 4 { - HIGH - } else if level_comp < -5 { - LOW - } else { - EQUAL - }) - .x_y(-37.0 * BARSIZE, MANA_BAR_Y + 9.0) + .w_h(18.0 * BARSIZE, 18.0 * BARSIZE) + .x_y(-39.0 * BARSIZE, MANA_BAR_Y + 7.0) + .color(Some(Color::Rgba(1.0, 1.0, 1.0, 1.0))) .parent(id) - .set(state.ids.level, ui); + .set(state.ids.level_skull, ui); + } else { + Text::new(&format!("{}", self.stats.level.level())) + .font_id(self.fonts.cyri.conrod_id) + .font_size(if self.stats.level.level() > 9 && level_comp < 10 { + 14 + } else { + 15 + }) + .color(if level_comp > 4 { + HIGH + } else if level_comp < -5 { + LOW + } else { + EQUAL + }) + .x_y(-37.0 * BARSIZE, MANA_BAR_Y + 9.0) + .parent(id) + .set(state.ids.level, ui); + } } } } diff --git a/voxygen/src/hud/settings_window.rs b/voxygen/src/hud/settings_window.rs index 9c92324eb3..142b4d0984 100644 --- a/voxygen/src/hud/settings_window.rs +++ b/voxygen/src/hud/settings_window.rs @@ -1190,6 +1190,10 @@ impl<'a> Widget for SettingsWindow<'a> { .color(TEXT_COLOR) .set(state.ids.chat_char_name_text, ui); + // TODO Show account name in chat + + // TODO Show account names in social window + // Language select drop down Text::new(&self.localized_strings.get("common.languages")) .down_from(state.ids.chat_char_name_button, 20.0) diff --git a/voxygen/src/hud/skillbar.rs b/voxygen/src/hud/skillbar.rs index 6fdb6cdbd5..907ac4a5af 100644 --- a/voxygen/src/hud/skillbar.rs +++ b/voxygen/src/hud/skillbar.rs @@ -219,10 +219,14 @@ impl<'a> Widget for Skillbar<'a> { let exp_percentage = (self.stats.exp.current() as f64) / (self.stats.exp.maximum() as f64); - let hp_percentage = + let mut hp_percentage = self.stats.health.current() as f64 / self.stats.health.maximum() as f64 * 100.0; - let energy_percentage = self.energy.current() as f64 / self.energy.maximum() as f64 * 100.0; - + let mut energy_percentage = + self.energy.current() as f64 / self.energy.maximum() as f64 * 100.0; + if self.stats.is_dead { + hp_percentage = 0.0; + energy_percentage = 0.0; + }; let scale = 2.0; let bar_values = self.global_state.settings.gameplay.bar_numbers; @@ -1153,15 +1157,14 @@ impl<'a> Widget for Skillbar<'a> { .w_h(100.0 * scale, 20.0 * scale) .top_left_with_margins_on(state.ids.m1_slot, 0.0, -100.0 * scale) .set(state.ids.healthbar_bg, ui); + let health_col = match hp_percentage as u8 { + 0..=20 => crit_hp_color, + 21..=40 => LOW_HP_COLOR, + _ => HP_COLOR, + }; Image::new(self.imgs.bar_content) .w_h(97.0 * scale * hp_percentage / 100.0, 16.0 * scale) - .color(Some(if hp_percentage <= 20.0 { - crit_hp_color - } else if hp_percentage <= 40.0 { - LOW_HP_COLOR - } else { - HP_COLOR - })) + .color(Some(health_col)) .top_right_with_margins_on(state.ids.healthbar_bg, 2.0 * scale, 1.0 * scale) .set(state.ids.healthbar_filling, ui); // Energybar @@ -1181,11 +1184,22 @@ impl<'a> Widget for Skillbar<'a> { // Bar Text // Values if let BarNumbers::Values = bar_values { - let hp_text = format!( + let mut hp_text = format!( "{}/{}", - (self.stats.health.current() / 10) as u32, + (self.stats.health.current() / 10).max(1) as u32, /* Don't show 0 health for + * living players */ (self.stats.health.maximum() / 10) as u32 ); + let mut energy_text = format!( + "{}/{}", + self.energy.current() as u32 / 10, /* TODO Fix regeneration with smaller energy + * numbers instead of dividing by 10 here */ + self.energy.maximum() as u32 / 10 + ); + if self.stats.is_dead { + hp_text = self.localized_strings.get("hud.group.dead").to_string(); + energy_text = self.localized_strings.get("hud.group.dead").to_string(); + }; Text::new(&hp_text) .mid_top_with_margin_on(state.ids.healthbar_bg, 6.0 * scale) .font_size(self.fonts.cyri.scale(14)) @@ -1198,12 +1212,6 @@ impl<'a> Widget for Skillbar<'a> { .font_id(self.fonts.cyri.conrod_id) .color(TEXT_COLOR) .set(state.ids.health_text, ui); - let energy_text = format!( - "{}/{}", - self.energy.current() as u32 / 10, /* TODO Fix regeneration with smaller energy - * numbers instead of dividing by 10 here */ - self.energy.maximum() as u32 / 10 - ); Text::new(&energy_text) .mid_top_with_margin_on(state.ids.energybar_bg, 6.0 * scale) .font_size(self.fonts.cyri.scale(14)) diff --git a/voxygen/src/hud/social.rs b/voxygen/src/hud/social.rs index 1bf7164bbe..78a9ef46c4 100644 --- a/voxygen/src/hud/social.rs +++ b/voxygen/src/hud/social.rs @@ -1,33 +1,60 @@ -use super::{img_ids::Imgs, Show, TEXT_COLOR, TEXT_COLOR_3, UI_MAIN}; +use super::{ + img_ids::{Imgs, ImgsRot}, + Show, TEXT_COLOR, TEXT_COLOR_3, UI_HIGHLIGHT_0, UI_MAIN, +}; -use crate::{i18n::VoxygenLocalization, ui::fonts::ConrodVoxygenFonts}; +use crate::{ + i18n::VoxygenLocalization, + ui::{fonts::ConrodVoxygenFonts, ImageFrame, Tooltip, TooltipManager, Tooltipable}, +}; use client::{self, Client}; +use common::{comp::group, sync::Uid}; use conrod_core::{ color, widget::{self, Button, Image, Rectangle, Scrollbar, Text}, - widget_ids, Colorable, Labelable, Positionable, Sizeable, Widget, WidgetCommon, + widget_ids, Color, Colorable, Labelable, Positionable, Sizeable, Widget, WidgetCommon, }; +use std::time::Instant; widget_ids! { pub struct Ids { - social_frame, - social_close, - social_title, frame, - align, - content_align, - online_tab, - friends_tab, - faction_tab, - online_title, - online_no, + close, + title_align, + title, + bg, + icon, scrollbar, + online_align, + online_tab, + names_align, + name_txt, + player_levels[], + player_names[], + player_zones[], + online_txt, + online_no, + levels_align, + level_txt, + zones_align, + zone_txt, + friends_tab, + //friends_tab_icon, + faction_tab, + //faction_tab_icon, friends_test, faction_test, - player_names[], + invite_button, } } +pub struct State { + ids: Ids, + // Holds the time when selection is made since this selection can be overriden + // by selecting an entity in-game + selected_uid: Option<(Uid, Instant)>, +} + pub enum SocialTab { Online, Friends, @@ -41,25 +68,35 @@ pub struct Social<'a> { imgs: &'a Imgs, fonts: &'a ConrodVoxygenFonts, localized_strings: &'a std::sync::Arc, + selected_entity: Option<(specs::Entity, Instant)>, + rot_imgs: &'a ImgsRot, + tooltip_manager: &'a mut TooltipManager, #[conrod(common_builder)] common: widget::CommonBuilder, } impl<'a> Social<'a> { + #[allow(clippy::too_many_arguments)] // TODO: Pending review in #587 pub fn new( show: &'a Show, client: &'a Client, imgs: &'a Imgs, fonts: &'a ConrodVoxygenFonts, localized_strings: &'a std::sync::Arc, + selected_entity: Option<(specs::Entity, Instant)>, + rot_imgs: &'a ImgsRot, + tooltip_manager: &'a mut TooltipManager, ) -> Self { Self { show, client, imgs, + rot_imgs, fonts, localized_strings, + tooltip_manager, + selected_entity, common: widget::CommonBuilder::default(), } } @@ -67,223 +104,467 @@ impl<'a> Social<'a> { pub enum Event { Close, + Invite(Uid), ChangeSocialTab(SocialTab), } impl<'a> Widget for Social<'a> { type Event = Vec; - type State = Ids; + type State = State; type Style = (); - fn init_state(&self, id_gen: widget::id::Generator) -> Self::State { Ids::new(id_gen) } + fn init_state(&self, id_gen: widget::id::Generator) -> Self::State { + Self::State { + ids: Ids::new(id_gen), + selected_uid: None, + } + } #[allow(clippy::unused_unit)] // TODO: Pending review in #587 fn style(&self) -> Self::Style { () } fn update(self, args: widget::UpdateArgs) -> Self::Event { - let widget::UpdateArgs { - /* id, */ state: ids, - ui, - .. - } = args; + let widget::UpdateArgs { state, ui, .. } = args; let mut events = Vec::new(); + let button_tooltip = Tooltip::new({ + // Edge images [t, b, r, l] + // Corner images [tr, tl, br, bl] + let edge = &self.rot_imgs.tt_side; + let corner = &self.rot_imgs.tt_corner; + ImageFrame::new( + [edge.cw180, edge.none, edge.cw270, edge.cw90], + [corner.none, corner.cw270, corner.cw90, corner.cw180], + Color::Rgba(0.08, 0.07, 0.04, 1.0), + 5.0, + ) + }) + .title_font_size(self.fonts.cyri.scale(15)) + .parent(ui.window) + .desc_font_size(self.fonts.cyri.scale(12)) + .title_text_color(TEXT_COLOR) + .font_id(self.fonts.cyri.conrod_id) + .desc_text_color(TEXT_COLOR); - Image::new(self.imgs.window_3) - .top_left_with_margins_on(ui.window, 200.0, 25.0) + // Window frame and BG + let pos = if self.show.group || self.show.group_menu { + 200.0 + } else { + 25.0 + }; + // TODO: Different window visuals depending on the selected tab + let window_bg = match &self.show.social_tab { + SocialTab::Online => self.imgs.social_bg_on, + SocialTab::Friends => self.imgs.social_bg_friends, + SocialTab::Faction => self.imgs.social_bg_fact, + }; + let window_frame = match &self.show.social_tab { + SocialTab::Online => self.imgs.social_frame_on, + SocialTab::Friends => self.imgs.social_frame_friends, + SocialTab::Faction => self.imgs.social_frame_fact, + }; + Image::new(window_bg) + .bottom_left_with_margins_on(ui.window, 308.0, pos) .color(Some(UI_MAIN)) - .w_h(103.0 * 4.0, 122.0 * 4.0) - .set(ids.social_frame, ui); - + .w_h(280.0, 460.0) + .set(state.ids.bg, ui); + Image::new(window_frame) + .middle_of(state.ids.bg) + .color(Some(UI_HIGHLIGHT_0)) + .w_h(280.0, 460.0) + .set(state.ids.frame, ui); + // Icon + Image::new(self.imgs.social) + .w_h(30.0, 30.0) + .top_left_with_margins_on(state.ids.frame, 6.0, 6.0) + .set(state.ids.icon, ui); // X-Button if Button::image(self.imgs.close_button) - .w_h(28.0, 28.0) + .w_h(24.0, 25.0) .hover_image(self.imgs.close_button_hover) .press_image(self.imgs.close_button_press) - .top_right_with_margins_on(ids.social_frame, 0.0, 0.0) - .set(ids.social_close, ui) + .top_right_with_margins_on(state.ids.frame, 0.0, 0.0) + .set(state.ids.close, ui) .was_clicked() { events.push(Event::Close); } // Title + Rectangle::fill_with([212.0, 42.0], color::TRANSPARENT) + .top_left_with_margins_on(state.ids.frame, 2.0, 44.0) + .set(state.ids.title_align, ui); Text::new(&self.localized_strings.get("hud.social")) - .mid_top_with_margin_on(ids.social_frame, 6.0) + .middle_of(state.ids.title_align) .font_id(self.fonts.cyri.conrod_id) - .font_size(self.fonts.cyri.scale(14)) + .font_size(self.fonts.cyri.scale(20)) .color(TEXT_COLOR) - .set(ids.social_title, ui); + .set(state.ids.title, ui); - // Alignment - Rectangle::fill_with([99.0 * 4.0, 112.0 * 4.0], color::TRANSPARENT) - .mid_top_with_margin_on(ids.social_frame, 8.0 * 4.0) - .set(ids.align, ui); - // Content Alignment - Rectangle::fill_with([94.0 * 4.0, 94.0 * 4.0], color::TRANSPARENT) - .middle_of(ids.frame) - .scroll_kids() - .scroll_kids_vertically() - .set(ids.content_align, ui); - Scrollbar::y_axis(ids.content_align) - .thickness(5.0) - .rgba(0.33, 0.33, 0.33, 1.0) - .set(ids.scrollbar, ui); - // Frame - Image::new(self.imgs.social_frame) - .w_h(99.0 * 4.0, 100.0 * 4.0) - .mid_bottom_of(ids.align) - .color(Some(UI_MAIN)) - .set(ids.frame, ui); - - // Online Tab - - if Button::image(if let SocialTab::Online = self.show.social_tab { - self.imgs.social_button_pressed - } else { - self.imgs.social_button + // Tabs Buttons + // Online Tab Button + if Button::image(match &self.show.social_tab { + SocialTab::Online => self.imgs.social_tab_online, + _ => self.imgs.social_tab_inact, }) - .w_h(30.0 * 4.0, 12.0 * 4.0) - .hover_image(if let SocialTab::Online = self.show.social_tab { - self.imgs.social_button_pressed - } else { - self.imgs.social_button_hover + .w_h(30.0, 44.0) + .image_color(match &self.show.social_tab { + SocialTab::Online => UI_MAIN, + _ => Color::Rgba(1.0, 1.0, 1.0, 0.6), }) - .press_image(if let SocialTab::Online = self.show.social_tab { - self.imgs.social_button_pressed - } else { - self.imgs.social_button_press - }) - .top_left_with_margins_on(ids.align, 4.0, 0.0) - .label(&self.localized_strings.get("hud.social.online")) - .label_font_size(self.fonts.cyri.scale(14)) - .label_font_id(self.fonts.cyri.conrod_id) - .parent(ids.frame) - .color(UI_MAIN) - .label_color(TEXT_COLOR) - .set(ids.online_tab, ui) + .top_right_with_margins_on(state.ids.frame, 50.0, -27.0) + .set(state.ids.online_tab, ui) .was_clicked() { events.push(Event::ChangeSocialTab(SocialTab::Online)); } - - // Contents - - if let SocialTab::Online = self.show.social_tab { - // TODO Needs to be a string sent from the server - - // Players list - // TODO: this list changes infrequently enough that it should not have to be - // recreated every frame - let players = self.client.player_list.values().filter(|p| p.is_online); - let count = players.clone().count(); - if ids.player_names.len() < count { - ids.update(|ids| { - ids.player_names - .resize(count, &mut ui.widget_id_generator()) - }) - } - Text::new( - &self - .localized_strings - .get("hud.social.play_online_fmt") - .replace("{nb_player}", &format!("{:?}", count)), - ) - .top_left_with_margins_on(ids.content_align, -2.0, 7.0) - .font_size(self.fonts.cyri.scale(14)) - .font_id(self.fonts.cyri.conrod_id) - .color(TEXT_COLOR) - .set(ids.online_title, ui); - for (i, player_info) in players.enumerate() { - Text::new(&format!( - "[{}] {}", - player_info.player_alias, - match &player_info.character { - Some(character) => format!("{} Lvl {}", &character.name, &character.level), - None => "".to_string(), // character select or spectating - } - )) - .down(3.0) - .font_size(self.fonts.cyri.scale(15)) - .font_id(self.fonts.cyri.conrod_id) - .color(TEXT_COLOR) - .set(ids.player_names[i], ui); - } - } - - // Friends Tab - - if Button::image(if let SocialTab::Friends = self.show.social_tab { - self.imgs.social_button_pressed - } else { - self.imgs.social_button + // Friends Tab Button + if Button::image(match &self.show.social_tab { + SocialTab::Friends => self.imgs.social_tab_act, + _ => self.imgs.social_tab_inact, }) - .w_h(30.0 * 4.0, 12.0 * 4.0) - .hover_image(if let SocialTab::Friends = self.show.social_tab { - self.imgs.social_button_pressed - } else { - self.imgs.social_button + .w_h(30.0, 44.0) + .hover_image(match &self.show.social_tab { + SocialTab::Friends => self.imgs.social_tab_act, + _ => self.imgs.social_tab_inact_hover, }) - .press_image(if let SocialTab::Friends = self.show.social_tab { - self.imgs.social_button_pressed - } else { - self.imgs.social_button + .press_image(match &self.show.social_tab { + SocialTab::Friends => self.imgs.social_tab_act, + _ => self.imgs.social_tab_inact_press, }) - .right_from(ids.online_tab, 0.0) - .label(&self.localized_strings.get("hud.social.friends")) - .label_font_size(self.fonts.cyri.scale(14)) - .label_font_id(self.fonts.cyri.conrod_id) - .parent(ids.frame) - .color(UI_MAIN) - .label_color(TEXT_COLOR_3) - .set(ids.friends_tab, ui) + .down_from(state.ids.online_tab, 0.0) + .image_color(match &self.show.social_tab { + SocialTab::Friends => UI_MAIN, + _ => Color::Rgba(1.0, 1.0, 1.0, 0.6), + }) + .set(state.ids.friends_tab, ui) .was_clicked() { events.push(Event::ChangeSocialTab(SocialTab::Friends)); } - - // Contents - - if let SocialTab::Friends = self.show.social_tab { - Text::new(&self.localized_strings.get("hud.social.not_yet_available")) - .middle_of(ids.content_align) - .font_size(self.fonts.cyri.scale(18)) - .font_id(self.fonts.cyri.conrod_id) - .color(TEXT_COLOR_3) - .set(ids.friends_test, ui); - } - - // Faction Tab - let button_img = if let SocialTab::Faction = self.show.social_tab { - self.imgs.social_button_pressed - } else { - self.imgs.social_button - }; - if Button::image(button_img) - .w_h(30.0 * 4.0, 12.0 * 4.0) - .right_from(ids.friends_tab, 0.0) - .label(&self.localized_strings.get("hud.social.faction")) - .parent(ids.frame) - .label_font_size(self.fonts.cyri.scale(14)) - .label_font_id(self.fonts.cyri.conrod_id) - .color(UI_MAIN) - .label_color(TEXT_COLOR_3) - .set(ids.faction_tab, ui) - .was_clicked() + // Faction Tab Button + if Button::image(match &self.show.social_tab { + SocialTab::Friends => self.imgs.social_tab_act, + _ => self.imgs.social_tab_inact, + }) + .w_h(30.0, 44.0) + .hover_image(match &self.show.social_tab { + SocialTab::Faction => self.imgs.social_tab_act, + _ => self.imgs.social_tab_inact_hover, + }) + .press_image(match &self.show.social_tab { + SocialTab::Faction => self.imgs.social_tab_act, + _ => self.imgs.social_tab_inact_press, + }) + .down_from(state.ids.friends_tab, 0.0) + .image_color(match &self.show.social_tab { + SocialTab::Faction => UI_MAIN, + _ => Color::Rgba(1.0, 1.0, 1.0, 0.6), + }) + .set(state.ids.faction_tab, ui) + .was_clicked() { events.push(Event::ChangeSocialTab(SocialTab::Faction)); } - - // Contents - - if let SocialTab::Faction = self.show.social_tab { - Text::new(&self.localized_strings.get("hud.social.not_yet_available")) - .middle_of(ids.content_align) - .font_size(self.fonts.cyri.scale(18)) + // Online Tab + if let SocialTab::Online = self.show.social_tab { + // Content Alignments + Rectangle::fill_with([270.0, 346.0], color::TRANSPARENT) + .mid_top_with_margin_on(state.ids.frame, 74.0) + .scroll_kids_vertically() + .set(state.ids.online_align, ui); + Rectangle::fill_with([133.0, 346.0], color::TRANSPARENT) + .top_left_with_margins_on(state.ids.online_align, 0.0, 0.0) + .crop_kids() + .set(state.ids.names_align, ui); + Rectangle::fill_with([39.0, 346.0], color::TRANSPARENT) + .right_from(state.ids.names_align, 2.0) + .crop_kids() + .set(state.ids.levels_align, ui); + Rectangle::fill_with([94.0, 346.0], color::TRANSPARENT) + .right_from(state.ids.levels_align, 2.0) + .crop_kids() + .set(state.ids.zones_align, ui); + Scrollbar::y_axis(state.ids.online_align) + .thickness(4.0) + .color(Color::Rgba(0.79, 1.09, 1.09, 0.0)) + .set(state.ids.scrollbar, ui); + // + // Headlines + // + if Button::image(self.imgs.nothing) + .w_h(133.0, 18.0) + .top_left_with_margins_on(state.ids.frame, 52.0, 7.0) + .label(&self.localized_strings.get("hud.social.name")) + .label_font_size(self.fonts.cyri.scale(14)) + .label_y(conrod_core::position::Relative::Scalar(0.0)) + .label_font_id(self.fonts.cyri.conrod_id) + .label_color(TEXT_COLOR) + .set(state.ids.name_txt, ui) + .was_clicked() + { + // Sort widgets by name alphabetically + } + if Button::image(self.imgs.nothing) + .w_h(39.0, 18.0) + .right_from(state.ids.name_txt, 2.0) + .label(&self.localized_strings.get("hud.social.level")) + .label_font_size(self.fonts.cyri.scale(14)) + .label_y(conrod_core::position::Relative::Scalar(0.0)) + .label_font_id(self.fonts.cyri.conrod_id) + .label_color(TEXT_COLOR) + .set(state.ids.level_txt, ui) + .was_clicked() + { + // Sort widgets by level (increasing) + } + if Button::image(self.imgs.nothing) + .w_h(93.0, 18.0) + .right_from(state.ids.level_txt, 2.0) + .label(&self.localized_strings.get("hud.social.zone")) + .label_font_size(self.fonts.cyri.scale(14)) + .label_y(conrod_core::position::Relative::Scalar(0.0)) + .label_font_id(self.fonts.cyri.conrod_id) + .label_color(TEXT_COLOR) + .set(state.ids.zone_txt, ui) + .was_clicked() + { + // Sort widgets by zone alphabetically + } + // Online Text + let players = self.client.player_list.iter().filter(|(_, p)| p.is_online); + let count = players.clone().count(); + Text::new(&self.localized_strings.get("hud.social.online")) + .bottom_left_with_margins_on(state.ids.frame, 18.0, 10.0) .font_id(self.fonts.cyri.conrod_id) - .color(TEXT_COLOR_3) - .set(ids.faction_test, ui); - } + .font_size(self.fonts.cyri.scale(14)) + .color(TEXT_COLOR) + .set(state.ids.online_txt, ui); + Text::new(&(count - 1).to_string()) + .right_from(state.ids.online_txt, 5.0) + .font_id(self.fonts.cyri.conrod_id) + .font_size(self.fonts.cyri.scale(14)) + .color(TEXT_COLOR) + .set(state.ids.online_no, ui); + // Adjust widget_id struct vec length to player count + if state.ids.player_levels.len() < count { + state.update(|s| { + s.ids + .player_levels + .resize(count, &mut ui.widget_id_generator()) + }) + }; + if state.ids.player_names.len() < count { + state.update(|s| { + s.ids + .player_names + .resize(count, &mut ui.widget_id_generator()) + }) + }; + if state.ids.player_zones.len() < count { + state.update(|s| { + s.ids + .player_zones + .resize(count, &mut ui.widget_id_generator()) + }) + }; + // Create a name, level and zone row for every player in the list + // Filter out yourself from the online list + let my_uid = self.client.uid(); + for (i, (&uid, player_info)) in + players.filter(|(uid, _)| Some(**uid) != my_uid).enumerate() + { + let hide_username = true; + let zone = "Wilderness"; // TODO Add real zone + let selected = state.selected_uid.map_or(false, |u| u.0 == uid); + let alias = &player_info.player_alias; + let name_text = match &player_info.character { + Some(character) => { + if Some(uid) == my_uid { + format!( + "{} ({})", + &self.localized_strings.get("hud.common.you"), + &character.name + ) + } else if hide_username { + character.name.clone() + } else { + format!("[{}] {}", alias, &character.name) + } + }, + None => alias.clone(), // character select or spectating + }; + let level = match &player_info.character { + Some(character) => format!("{} ", &character.level), + None => "".to_string(), // character select or spectating + }; + let zone_name = match &player_info.character { + None => self.localized_strings.get("hud.group.in_menu").to_string(), /* character select or spectating */ + _ => format!("{} ", &zone), + }; + // Player name widgets + let button = Button::image(if !selected { + self.imgs.nothing + } else { + self.imgs.selection + }); + let button = if i == 0 { + button.mid_top_with_margin_on(state.ids.names_align, 1.0) + } else { + button.down_from(state.ids.player_names[i - 1], 1.0) + }; + button + .w_h(133.0, 20.0) + .hover_image(if selected { + self.imgs.selection + } else { + self.imgs.selection_hover + }) + .press_image(if selected { + self.imgs.selection + } else { + self.imgs.selection_press + }) + .label(&name_text) + .label_font_size(self.fonts.cyri.scale(14)) + .label_y(conrod_core::position::Relative::Scalar(1.0)) + .label_font_id(self.fonts.cyri.conrod_id) + .label_color(TEXT_COLOR) + .set(state.ids.player_names[i], ui); + // Player Levels + Button::image(if !selected { + self.imgs.nothing + } else { + self.imgs.selection + }) + .w_h(39.0, 20.0) + .right_from(state.ids.player_names[i], 2.0) + .label(&level) + .label_font_size(self.fonts.cyri.scale(14)) + .label_font_id(self.fonts.cyri.conrod_id) + .label_color(TEXT_COLOR) + .label_y(conrod_core::position::Relative::Scalar(1.0)) + .parent(state.ids.levels_align) + .set(state.ids.player_levels[i], ui); + // Player Zones + Button::image(if !selected { + self.imgs.nothing + } else { + self.imgs.selection + }) + .w_h(94.0, 20.0) + .right_from(state.ids.player_levels[i], 2.0) + .label(&zone_name) + .label_font_size(self.fonts.cyri.scale(14)) + .label_font_id(self.fonts.cyri.conrod_id) + .label_color(TEXT_COLOR) + .label_y(conrod_core::position::Relative::Scalar(1.0)) + .parent(state.ids.zones_align) + .set(state.ids.player_zones[i], ui); + // Check for click + if ui + .widget_input(state.ids.player_names[i]) + .clicks() + .left() + .next() + .is_some() + { + state.update(|s| s.selected_uid = Some((uid, Instant::now()))); + } + } + + // Invite Button + let is_leader_or_not_in_group = self + .client + .group_info() + .map_or(true, |(_, l_uid)| self.client.uid() == Some(l_uid)); + + let current_members = self + .client + .group_members() + .iter() + .filter(|(_, role)| matches!(role, group::Role::Member)) + .count() + + 1; + let current_invites = self.client.pending_invites().len(); + let max_members = self.client.max_group_size() as usize; + let group_not_full = current_members + current_invites < max_members; + let selected_to_invite = (is_leader_or_not_in_group && group_not_full) + .then(|| { + state + .selected_uid + .as_ref() + .map(|(s, _)| *s) + .filter(|selected| { + self.client + .player_list + .get(selected) + .map_or(false, |selected_player| { + selected_player.is_online && selected_player.character.is_some() + }) + }) + .or_else(|| { + self.selected_entity + .and_then(|s| self.client.state().read_component_copied(s.0)) + }) + .filter(|selected| { + // Prevent inviting entities already in the same group + !self.client.group_members().contains_key(selected) + }) + }) + .flatten(); + + let invite_button = Button::image(self.imgs.button) + .w_h(106.0, 26.0) + .bottom_right_with_margins_on(state.ids.frame, 9.0, 7.0) + .hover_image(if selected_to_invite.is_some() { + self.imgs.button_hover + } else { + self.imgs.button + }) + .press_image(if selected_to_invite.is_some() { + self.imgs.button_press + } else { + self.imgs.button + }) + .label(self.localized_strings.get("hud.group.invite")) + .label_y(conrod_core::position::Relative::Scalar(3.0)) + .label_color(if selected_to_invite.is_some() { + TEXT_COLOR + } else { + TEXT_COLOR_3 + }) + .image_color(if selected_to_invite.is_some() { + TEXT_COLOR + } else { + TEXT_COLOR_3 + }) + .label_font_size(self.fonts.cyri.scale(15)) + .label_font_id(self.fonts.cyri.conrod_id); + + if if self.client.group_info().is_some() { + let tooltip_txt = format!( + "{}/{} {}", + current_members + current_invites, + max_members, + &self.localized_strings.get("hud.group.members") + ); + invite_button + .with_tooltip(self.tooltip_manager, &tooltip_txt, "", &button_tooltip) + .set(state.ids.invite_button, ui) + } else { + invite_button.set(state.ids.invite_button, ui) + } + .was_clicked() + { + if let Some(uid) = selected_to_invite { + events.push(Event::Invite(uid)); + state.update(|s| { + s.selected_uid = None; + }); + } + } + } // End of Online Tab events } diff --git a/voxygen/src/scene/figure/mod.rs b/voxygen/src/scene/figure/mod.rs index 16fb4d1bcd..980433f024 100644 --- a/voxygen/src/scene/figure/mod.rs +++ b/voxygen/src/scene/figure/mod.rs @@ -22,8 +22,8 @@ use anim::{ }; use common::{ comp::{ - item::ItemKind, Body, CharacterState, Last, LightAnimation, LightEmitter, Loadout, Ori, - PhysicsState, Pos, Scale, Stats, Vel, + item::ItemKind, Body, CharacterState, Item, Last, LightAnimation, LightEmitter, Loadout, + Ori, PhysicsState, Pos, Scale, Stats, Vel, }, state::{DeltaTime, State}, states::triple_strike, @@ -192,7 +192,6 @@ impl FigureMgr { .read_storage::() .get(scene_data.player_entity) .map_or(Vec3::zero(), |pos| pos.0); - for ( i, ( @@ -207,6 +206,7 @@ impl FigureMgr { physics, stats, loadout, + item, ), ) in ( &ecs.entities(), @@ -220,6 +220,7 @@ impl FigureMgr { &ecs.read_storage::(), ecs.read_storage::().maybe(), ecs.read_storage::().maybe(), + ecs.read_storage::().maybe(), ) .join() .enumerate() @@ -519,7 +520,13 @@ impl FigureMgr { (c / (1.0 + DAMAGE_FADE_COEFFICIENT * s.health.last_change.0)) as f32 }) }) - .unwrap_or(Rgba::broadcast(1.0)); + .unwrap_or(Rgba::broadcast(1.0)) + // Highlight targeted collectible entities + * if item.is_some() && scene_data.target_entity.map_or(false, |e| e == entity) { + Rgba::new(2.0, 2.0, 2.0, 1.0) + } else { + Rgba::one() + }; let scale = scale.map(|s| s.0).unwrap_or(1.0); diff --git a/voxygen/src/scene/mod.rs b/voxygen/src/scene/mod.rs index 5f449fe15c..4c18a92207 100644 --- a/voxygen/src/scene/mod.rs +++ b/voxygen/src/scene/mod.rs @@ -70,6 +70,7 @@ pub struct Scene { pub struct SceneData<'a> { pub state: &'a State, pub player_entity: specs::Entity, + pub target_entity: Option, pub loaded_distance: f32, pub view_distance: u32, pub tick: u64, diff --git a/voxygen/src/session.rs b/voxygen/src/session.rs index 00bffa5338..3c5f266164 100644 --- a/voxygen/src/session.rs +++ b/voxygen/src/session.rs @@ -52,6 +52,8 @@ pub struct SessionState { free_look: bool, auto_walk: bool, is_aiming: bool, + target_entity: Option, + selected_entity: Option<(specs::Entity, std::time::Instant)>, } /// Represents an active game session (i.e., the one being played). @@ -86,6 +88,8 @@ impl SessionState { free_look: false, auto_walk: false, is_aiming: false, + target_entity: None, + selected_entity: None, } } @@ -208,18 +212,6 @@ impl PlayState for SessionState { view_mat, cam_pos, .. } = self.scene.camera().dependents(); - // Choose a spot above the player's head for item distance checks - let player_pos = match self - .client - .borrow() - .state() - .read_storage::() - .get(self.client.borrow().entity()) - { - Some(pos) => pos.0 + (Vec3::unit_z() * 2.0), - _ => cam_pos, // Should never happen, but a safe fallback - }; - let (is_aiming, aim_dir_offset) = { let client = self.client.borrow(); let is_aiming = client @@ -243,30 +235,10 @@ impl PlayState for SessionState { let cam_dir: Vec3 = Vec3::from(view_mat.inverted() * -Vec4::unit_z()); // Check to see whether we're aiming at anything - let (build_pos, select_pos) = { - let client = self.client.borrow(); - let terrain = client.state().terrain(); - - let cam_ray = terrain - .ray(cam_pos, cam_pos + cam_dir * 100.0) - .until(|block| block.is_tangible()) - .cast(); - - let cam_dist = cam_ray.0; - - match cam_ray.1 { - Ok(Some(_)) - if player_pos.distance_squared(cam_pos + cam_dir * cam_dist) - <= MAX_PICKUP_RANGE_SQR => - { - ( - Some((cam_pos + cam_dir * (cam_dist - 0.01)).map(|e| e.floor() as i32)), - Some((cam_pos + cam_dir * (cam_dist + 0.01)).map(|e| e.floor() as i32)), - ) - }, - _ => (None, None), - } - }; + let (build_pos, select_pos, target_entity) = + under_cursor(&self.client.borrow(), cam_pos, cam_dir); + // Throw out distance info, it will be useful in the future + self.target_entity = target_entity.map(|x| x.0); let can_build = self .client @@ -559,6 +531,24 @@ impl PlayState for SessionState { let camera = self.scene.camera_mut(); camera.next_mode(self.client.borrow().is_admin()); }, + Event::InputUpdate(GameInput::Select, state) => { + if !state { + self.selected_entity = + self.target_entity.map(|e| (e, std::time::Instant::now())); + } + }, + Event::InputUpdate(GameInput::AcceptGroupInvite, true) => { + let mut client = self.client.borrow_mut(); + if client.group_invite().is_some() { + client.accept_group_invite(); + } + }, + Event::InputUpdate(GameInput::DeclineGroupInvite, true) => { + let mut client = self.client.borrow_mut(); + if client.group_invite().is_some() { + client.decline_group_invite(); + } + }, Event::AnalogGameInput(input) => match input { AnalogGameInput::MovementX(v) => { self.key_state.analog_matrix.x = v; @@ -708,6 +698,8 @@ impl PlayState for SessionState { self.scene.camera().get_mode(), camera::CameraMode::FirstPerson ), + target_entity: self.target_entity, + selected_entity: self.selected_entity, }, ); @@ -971,6 +963,24 @@ impl PlayState for SessionState { HudEvent::CraftRecipe(r) => { self.client.borrow_mut().craft_recipe(&r); }, + HudEvent::InviteMember(uid) => { + self.client.borrow_mut().send_group_invite(uid); + }, + HudEvent::AcceptInvite => { + self.client.borrow_mut().accept_group_invite(); + }, + HudEvent::DeclineInvite => { + self.client.borrow_mut().decline_group_invite(); + }, + HudEvent::KickMember(uid) => { + self.client.borrow_mut().kick_from_group(uid); + }, + HudEvent::LeaveGroup => { + self.client.borrow_mut().leave_group(); + }, + HudEvent::AssignLeader(uid) => { + self.client.borrow_mut().assign_group_leader(uid); + }, } } @@ -979,6 +989,7 @@ impl PlayState for SessionState { let scene_data = SceneData { state: client.state(), player_entity: client.entity(), + target_entity: self.target_entity, loaded_distance: client.loaded_distance(), view_distance: client.view_distance().unwrap_or(1), tick: client.get_tick(), @@ -1033,6 +1044,7 @@ impl PlayState for SessionState { let scene_data = SceneData { state: client.state(), player_entity: client.entity(), + target_entity: self.target_entity, loaded_distance: client.loaded_distance(), view_distance: client.view_distance().unwrap_or(1), tick: client.get_tick(), @@ -1055,3 +1067,108 @@ impl PlayState for SessionState { self.hud.render(renderer, self.scene.globals()); } } + +/// Max distance an entity can be "targeted" +const MAX_TARGET_RANGE: f32 = 30.0; +/// Calculate what the cursor is pointing at within the 3d scene +#[allow(clippy::type_complexity)] +fn under_cursor( + client: &Client, + cam_pos: Vec3, + cam_dir: Vec3, +) -> ( + Option>, + Option>, + Option<(specs::Entity, f32)>, +) { + // Choose a spot above the player's head for item distance checks + let player_entity = client.entity(); + let player_pos = match client + .state() + .read_storage::() + .get(player_entity) + { + Some(pos) => pos.0 + (Vec3::unit_z() * 2.0), + _ => cam_pos, // Should never happen, but a safe fallback + }; + let terrain = client.state().terrain(); + + let cam_ray = terrain + .ray(cam_pos, cam_pos + cam_dir * 100.0) + .until(|block| block.is_tangible()) + .cast(); + + let cam_dist = cam_ray.0; + + // The ray hit something, is it within range? + let (build_pos, select_pos) = if matches!(cam_ray.1, Ok(Some(_)) if + player_pos.distance_squared(cam_pos + cam_dir * cam_dist) + <= MAX_PICKUP_RANGE_SQR) + { + ( + Some((cam_pos + cam_dir * (cam_dist - 0.01)).map(|e| e.floor() as i32)), + Some((cam_pos + cam_dir * (cam_dist + 0.01)).map(|e| e.floor() as i32)), + ) + } else { + (None, None) + }; + + // See if ray hits entities + // Currently treated as spheres + let ecs = client.state().ecs(); + // Don't cast through blocks + // Could check for intersection with entity from last frame to narrow this down + let cast_dist = if let Ok(Some(_)) = cam_ray.1 { + cam_dist.min(MAX_TARGET_RANGE) + } else { + MAX_TARGET_RANGE + }; + + // Need to raycast by distance to cam + // But also filter out by distance to the player (but this only needs to be done + // on final result) + let mut nearby = ( + &ecs.entities(), + &ecs.read_storage::(), + ecs.read_storage::().maybe(), + &ecs.read_storage::() + ) + .join() + .filter(|(e, _, _, _)| *e != player_entity) + .map(|(e, p, s, b)| { + const RADIUS_SCALE: f32 = 3.0; + let radius = s.map_or(1.0, |s| s.0) * b.radius() * RADIUS_SCALE; + // Move position up from the feet + let pos = Vec3::new(p.0.x, p.0.y, p.0.z + radius); + // Distance squared from camera to the entity + let dist_sqr = pos.distance_squared(cam_pos); + (e, pos, radius, dist_sqr) + }) + // Roughly filter out entities farther than ray distance + .filter(|(_, _, r, d_sqr)| *d_sqr <= cast_dist.powi(2) + 2.0 * cast_dist * r + r.powi(2)) + // Ignore entities intersecting the camera + .filter(|(_, _, r, d_sqr)| *d_sqr > r.powi(2)) + // Substract sphere radius from distance to the camera + .map(|(e, p, r, d_sqr)| (e, p, r, d_sqr.sqrt() - r)) + .collect::>(); + // Sort by distance + nearby.sort_unstable_by(|a, b| a.3.partial_cmp(&b.3).unwrap()); + + let seg_ray = LineSegment3 { + start: cam_pos, + end: cam_pos + cam_dir * cam_dist, + }; + // TODO: fuzzy borders + let target_entity = nearby + .iter() + .map(|(e, p, r, _)| (e, *p, r)) + // Find first one that intersects the ray segment + .find(|(_, p, r)| seg_ray.projected_point(*p).distance_squared(*p) < r.powi(2)) + .and_then(|(e, p, r)| { + let dist_to_player = p.distance(player_pos); + (dist_to_player - r < MAX_TARGET_RANGE).then_some((*e, dist_to_player)) + }); + + // TODO: consider setting build/select to None when targeting an entity + (build_pos, select_pos, target_entity) +} diff --git a/voxygen/src/settings.rs b/voxygen/src/settings.rs index 1767ee8764..441108bdb7 100644 --- a/voxygen/src/settings.rs +++ b/voxygen/src/settings.rs @@ -169,6 +169,9 @@ impl ControlSettings { GameInput::Slot9 => KeyMouse::Key(VirtualKeyCode::Key9), GameInput::Slot10 => KeyMouse::Key(VirtualKeyCode::Q), GameInput::SwapLoadout => KeyMouse::Key(VirtualKeyCode::LAlt), + GameInput::Select => KeyMouse::Key(VirtualKeyCode::Y), + GameInput::AcceptGroupInvite => KeyMouse::Key(VirtualKeyCode::U), + GameInput::DeclineGroupInvite => KeyMouse::Key(VirtualKeyCode::I), } } } @@ -234,6 +237,9 @@ impl Default for ControlSettings { GameInput::Slot9, GameInput::Slot10, GameInput::SwapLoadout, + GameInput::Select, + GameInput::AcceptGroupInvite, + GameInput::DeclineGroupInvite, ]; for game_input in game_inputs { new_settings.insert_binding(game_input, ControlSettings::default_binding(game_input)); diff --git a/voxygen/src/window.rs b/voxygen/src/window.rs index c634a332f5..b8bf69c08b 100644 --- a/voxygen/src/window.rs +++ b/voxygen/src/window.rs @@ -67,6 +67,9 @@ pub enum GameInput { FreeLook, AutoWalk, CycleCamera, + Select, + AcceptGroupInvite, + DeclineGroupInvite, } impl GameInput { @@ -123,6 +126,9 @@ impl GameInput { GameInput::Slot9 => "gameinput.slot9", GameInput::Slot10 => "gameinput.slot10", GameInput::SwapLoadout => "gameinput.swaploadout", + GameInput::Select => "gameinput.select", + GameInput::AcceptGroupInvite => "gameinput.acceptgroupinvite", + GameInput::DeclineGroupInvite => "gameinput.declinegroupinvite", } } diff --git a/world/src/site/dungeon/mod.rs b/world/src/site/dungeon/mod.rs index 34d6a0aebf..10cecd7807 100644 --- a/world/src/site/dungeon/mod.rs +++ b/world/src/site/dungeon/mod.rs @@ -522,7 +522,7 @@ impl Floor { "common.items.armor.shoulder.cultist_shoulder_purple", ), 8 => comp::Item::expect_from_asset( - "common.items.weapons.sword.greatsword_2h_fine-0", + "common.items.weapons.staff.cultist_staff", ), 9 => comp::Item::expect_from_asset( "common.items.weapons.sword.greatsword_2h_fine-1",