package tc.oc.pgm.scoreboard; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import javax.annotation.Nullable; import javax.inject.Inject; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Ordering; import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.chat.BaseComponent; import org.apache.commons.lang.StringUtils; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.scoreboard.DisplaySlot; import org.bukkit.scoreboard.Objective; import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.Team; import java.time.Duration; import tc.oc.commons.bukkit.chat.ComponentRenderers; import tc.oc.commons.bukkit.chat.NameStyle; import tc.oc.commons.bukkit.util.NullCommandSender; import tc.oc.commons.core.chat.Component; import tc.oc.commons.core.scheduler.Task; import tc.oc.pgm.Config; import tc.oc.pgm.blitz.LivesEvent; import tc.oc.pgm.destroyable.Destroyable; import tc.oc.pgm.events.FeatureChangeEvent; import tc.oc.pgm.events.ListenerScope; import tc.oc.pgm.events.MatchPlayerDeathEvent; import tc.oc.pgm.events.MatchResultChangeEvent; import tc.oc.pgm.events.MatchScoreChangeEvent; import tc.oc.pgm.events.PartyAddEvent; import tc.oc.pgm.events.PartyRemoveEvent; import tc.oc.pgm.events.PartyRenameEvent; import tc.oc.pgm.events.PlayerPartyChangeEvent; import tc.oc.pgm.ffa.Tribute; import tc.oc.pgm.goals.Goal; import tc.oc.pgm.goals.GoalMatchModule; import tc.oc.pgm.goals.ProximityGoal; import tc.oc.pgm.goals.events.GoalCompleteEvent; import tc.oc.pgm.goals.events.GoalProximityChangeEvent; import tc.oc.pgm.goals.events.GoalStatusChangeEvent; import tc.oc.pgm.goals.events.GoalTouchEvent; import tc.oc.pgm.blitz.Lives; import tc.oc.pgm.blitz.BlitzEvent; import tc.oc.pgm.blitz.BlitzMatchModule; import tc.oc.pgm.match.Competitor; import tc.oc.pgm.match.Match; import tc.oc.pgm.match.MatchModule; import tc.oc.pgm.match.MatchScope; import tc.oc.pgm.match.Party; import tc.oc.pgm.score.ScoreMatchModule; import tc.oc.pgm.spawns.events.ParticipantSpawnEvent; import tc.oc.pgm.teams.events.TeamRespawnsChangeEvent; import tc.oc.pgm.victory.VictoryMatchModule; import tc.oc.pgm.wool.MonumentWool; import tc.oc.pgm.wool.MonumentWoolFactory; import static tc.oc.commons.core.util.Nullables.castOrNull; @ListenerScope(MatchScope.LOADED) public class SidebarMatchModule extends MatchModule implements Listener { public static final int MAX_ROWS = 16; // Max rows on the scoreboard public static final int MAX_PREFIX = 16; // Max chars in a team prefix public static final int MAX_SUFFIX = 16; // Max chars in a team suffix @Inject private List<MonumentWoolFactory> wools; @Inject private BlitzMatchModule blitz; private final String legacyTitle; protected final Map<Party, Sidebar> sidebars = new HashMap<>(); protected final Map<Goal, BlinkTask> blinkingGoals = new HashMap<>(); private class Sidebar { private static final String IDENTIFIER = "pgm"; private final Scoreboard scoreboard; private final Objective objective; // Each row has its own scoreboard team protected final String[] rows = new String[MAX_ROWS]; protected final int[] scores = new int[MAX_ROWS]; protected final Team[] teams = new Team[MAX_ROWS]; protected final String[] players = new String[MAX_ROWS]; private Sidebar(Party party) { this.scoreboard = getMatch().needMatchModule(ScoreboardMatchModule.class).getScoreboard(party); this.objective = this.scoreboard.registerNewObjective(IDENTIFIER, "dummy"); this.objective.setDisplayName(legacyTitle); this.objective.setDisplaySlot(DisplaySlot.SIDEBAR); for(int i = 0; i < MAX_ROWS; ++i) { this.rows[i] = null; this.scores[i] = -1; this.players[i] = String.valueOf(ChatColor.COLOR_CHAR) + (char) i; this.teams[i] = this.scoreboard.registerNewTeam(IDENTIFIER + "-row-" + i); this.teams[i].setPrefix(""); this.teams[i].setSuffix(""); this.teams[i].addEntry(this.players[i]); } } public Scoreboard getScoreboard() { return this.scoreboard; } public Objective getObjective() { return this.objective; } private void setRow(int maxScore, int row, @Nullable String text) { if(row < 0 || row >= MAX_ROWS) return; int score = text == null ? -1 : maxScore - row - 1; if(this.scores[row] != score) { this.scores[row] = score; if(score == -1) { this.scoreboard.resetScores(this.players[row]); } else { this.objective.getScore(this.players[row]).setScore(score); } } if(!Objects.equals(this.rows[row], text)) { this.rows[row] = text; if(text != null) { /* Split the row text into prefix and suffix, limited to 16 chars each. Because the player name is a color code, we have to restore the color at the split in the suffix. We also have to be careful not to split in the middle of a color code. */ int split = MAX_PREFIX - 1; // Start by assuming there is a color code right on the split if(text.length() < MAX_PREFIX || text.charAt(split) != ChatColor.COLOR_CHAR) { // If there isn't, we can fit one more char in the prefix split++; } // Split and truncate the text, and restore the color in the suffix String prefix = StringUtils.substring(text, 0, split); String lastColors = org.bukkit.ChatColor.getLastColors(prefix); String suffix = lastColors + StringUtils.substring(text, split, split + MAX_SUFFIX - lastColors.length()); this.teams[row].setPrefix(prefix); this.teams[row].setSuffix(suffix); } } } } public SidebarMatchModule(Match match, BaseComponent title) { super(match); this.legacyTitle = StringUtils.left( ComponentRenderers.toLegacyText( new Component(title, ChatColor.AQUA), NullCommandSender.INSTANCE ), 32 ); } private boolean hasScores() { return getMatch().getMatchModule(ScoreMatchModule.class) != null; } private boolean lives(Lives.Type type) { return blitz.activated() && blitz.properties().type.equals(type); } private boolean isCompactWool() { final int woolTeams = (int) wools.stream() .map(MonumentWoolFactory::getOwner) .distinct() .count(); return !wools.isEmpty() && MAX_ROWS < woolTeams * 2 - 1 + wools.size(); } private void addSidebar(Party party) { logger.fine("Adding sidebar for party " + party); sidebars.put(party, new Sidebar(party)); } @Override public void load() { super.load(); for(Party party : getMatch().getParties()) addSidebar(party); renderSidebarDebounce(); } @Override public void enable() { super.enable(); renderSidebarDebounce(); } @Override public void disable() { for(BlinkTask task : ImmutableSet.copyOf(this.blinkingGoals.values())) { task.stop(); } } @EventHandler public void addParty(PartyAddEvent event) { addSidebar(event.getParty()); renderSidebarDebounce(); } @EventHandler public void removeParty(PartyRemoveEvent event) { logger.fine("Removing sidebar for party " + event.getParty()); sidebars.remove(event.getParty()); renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPartyChange(PlayerPartyChangeEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.LOWEST) public void onDeath(MatchPlayerDeathEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.LOWEST) public void onSpawn(ParticipantSpawnEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void onPartyRename(final PartyRenameEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void scoreChange(final MatchScoreChangeEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void goalTouch(final GoalTouchEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void goalStatusChange(final GoalStatusChangeEvent event) { if(event.getGoal() instanceof Destroyable && ((Destroyable) event.getGoal()).getShowProgress()) { blinkGoal(event.getGoal(), 3, Duration.ofSeconds(1)); } else { renderSidebarDebounce(); } } @EventHandler(priority = EventPriority.MONITOR) public void goalProximityChange(final GoalProximityChangeEvent event) { if(Config.Scoreboard.showProximity()) { renderSidebarDebounce(); } } @EventHandler(priority = EventPriority.MONITOR) public void goalComplete(final GoalCompleteEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void goalChange(final FeatureChangeEvent event) { if (event.getFeature() instanceof Goal) { renderSidebarDebounce(); } } @EventHandler(priority = EventPriority.MONITOR) public void updateRespawnLimit(final TeamRespawnsChangeEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void resultChange(MatchResultChangeEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void blitzEnable(BlitzEvent event) { renderSidebarDebounce(); } @EventHandler(priority = EventPriority.MONITOR) public void livesChange(LivesEvent event) { renderSidebarDebounce(); } private String renderGoal(Goal<?> goal, @Nullable Competitor competitor, Party viewingParty) { StringBuilder sb = new StringBuilder(" "); BlinkTask blinkTask = this.blinkingGoals.get(goal); if(blinkTask != null && blinkTask.isDark()) { sb.append(ChatColor.BLACK); } else { sb.append(goal.renderSidebarStatusColor(competitor, viewingParty)); } sb.append(goal.renderSidebarStatusText(competitor, viewingParty)); if(goal instanceof ProximityGoal) { sb.append(" "); // Show teams their own proximity on shared goals Competitor proximityCompetitor = competitor != null ? competitor : castOrNull(viewingParty, Competitor.class); sb.append(((ProximityGoal) goal).renderProximity(proximityCompetitor, viewingParty)); } sb.append(" "); sb.append(goal.renderSidebarLabelColor(competitor, viewingParty)); sb.append(goal.renderSidebarLabelText(competitor, viewingParty)); return sb.toString(); } private String renderScore(Competitor competitor, Party viewingParty) { ScoreMatchModule smm = getMatch().needMatchModule(ScoreMatchModule.class); String text = ChatColor.WHITE.toString() + (int) smm.getScore(competitor); if(smm.hasScoreLimit()) { text += ChatColor.DARK_GRAY + "/" + ChatColor.GRAY + smm.getScoreLimit(); } return text; } private String renderBlitz(Competitor competitor, Party viewingParty) { if(competitor instanceof tc.oc.pgm.teams.Team) { return ChatColor.WHITE.toString() + competitor.getPlayers().size(); } else if(competitor instanceof Tribute && blitz.properties().multipleLives()) { return ChatColor.WHITE.toString() + blitz.livesCount(competitor.getPlayers().iterator().next()); } else { return ""; } } private void renderSidebarDebounce() { match.getScheduler(MatchScope.LOADED).debounceTask(this::renderSidebar); } private void renderSidebar() { final boolean hasScores = hasScores(); final boolean hasIndividualLives = lives(Lives.Type.INDIVIDUAL); final boolean hasTeamLives = lives(Lives.Type.TEAM); final GoalMatchModule gmm = match.needMatchModule(GoalMatchModule.class); Set<Competitor> competitorsWithGoals = new HashSet<>(); List<Goal> sharedGoals = new ArrayList<>(); // Count the rows used for goals for(Goal goal : gmm.getGoals()) { if(goal.isVisible()) { if(goal.isShared()) { sharedGoals.add(goal); } else { for(Competitor competitor : gmm.getCompetitors(goal)) { competitorsWithGoals.add(competitor); } } } } for(Map.Entry<Party, Sidebar> entry : this.sidebars.entrySet()) { Party viewingParty = entry.getKey(); Sidebar sidebar = entry.getValue(); List<String> rows = new ArrayList<>(MAX_ROWS); // Scores/Blitz if(hasScores || hasIndividualLives || (hasTeamLives && competitorsWithGoals.isEmpty())) { for(Competitor competitor : getMatch().needMatchModule(VictoryMatchModule.class).rankedCompetitors()) { String text; if(hasScores) { text = renderScore(competitor, viewingParty); } else { text = renderBlitz(competitor, viewingParty); } if(text.length() != 0) text += " "; rows.add(text + ComponentRenderers.toLegacyText(competitor.getStyledName(NameStyle.GAME), NullCommandSender.INSTANCE)); } if(!competitorsWithGoals.isEmpty() || !sharedGoals.isEmpty()) { // Blank row between scores and goals rows.add(""); } } boolean firstTeam = true; // Shared goals i.e. not grouped under a specific team for(Goal goal : sharedGoals) { firstTeam = false; rows.add(this.renderGoal(goal, null, viewingParty)); } // Team-specific goals List<Competitor> sortedCompetitors = new ArrayList<>(competitorsWithGoals); if(viewingParty instanceof Competitor) { // Participants see competitors in arbitrary order, with their own at the top Collections.sort(sortedCompetitors, Ordering.arbitrary()); // Bump viewing party to the top of the list if(sortedCompetitors.remove(viewingParty)) { sortedCompetitors.add(0, (Competitor) viewingParty); } } else { // Observers see the competitors sorted by closeness to winning Collections.sort(sortedCompetitors, match.needMatchModule(VictoryMatchModule.class).victoryOrder()); } for(Competitor competitor : sortedCompetitors) { if(!firstTeam) { // Add a blank row between teams rows.add(""); } firstTeam = false; // Add a row for the team name rows.add(ComponentRenderers.toLegacyText(competitor.getStyledName(NameStyle.GAME), NullCommandSender.INSTANCE)); // Add lives status under the team name if(hasTeamLives) { blitz.lives(competitor).ifPresent(l -> rows.add(ComponentRenderers.toLegacyText(l.status(), NullCommandSender.INSTANCE))); } if(isCompactWool()) { String woolText = " "; boolean firstWool = true; List<Goal> sortedWools = new ArrayList<>(gmm.getGoals(competitor)); Collections.sort(sortedWools, new Comparator<Goal>() { @Override public int compare(Goal a, Goal b) { return a.getName().compareToIgnoreCase(b.getName()); }}); for(Goal goal : sortedWools) { if(goal instanceof MonumentWool && goal.isVisible()) { MonumentWool wool = (MonumentWool) goal; if(!firstWool) { woolText += " "; } firstWool = false; woolText += wool.renderSidebarStatusColor(competitor, viewingParty); woolText += wool.renderSidebarStatusText(competitor, viewingParty); } } rows.add(woolText); } else { // Add a row for each of this team's goals for(Goal goal : gmm.getGoals()) { if(!goal.isShared() && goal.canComplete(competitor) && goal.isVisible()) { rows.add(this.renderGoal(goal, competitor, viewingParty)); } } } } // Need at least one row for the sidebar to show if(rows.isEmpty()) { rows.add(""); } for(int i = 0; i < MAX_ROWS; i++) { if(i < rows.size()) { sidebar.setRow(rows.size(), i, rows.get(i)); } else { sidebar.setRow(rows.size(), i, null); } } } } public void blinkGoal(Goal goal, float rateHz, @Nullable Duration duration) { BlinkTask task = this.blinkingGoals.get(goal); if(task != null) { task.reset(duration); } else { this.blinkingGoals.put(goal, new BlinkTask(goal, rateHz, duration)); } } public void stopBlinkingGoal(Goal goal) { BlinkTask task = this.blinkingGoals.remove(goal); if(task != null) task.stop(); } private class BlinkTask implements Runnable { private final Task task; private final Goal goal; private final long intervalTicks; private boolean dark; private Long ticksRemaining; private BlinkTask(Goal goal, float rateHz, @Nullable Duration duration) { this.goal = goal; this.intervalTicks = (long) (10f / rateHz); this.task = getMatch().getScheduler(MatchScope.RUNNING).createRepeatingTask(0, intervalTicks, this); this.reset(duration); } public void reset(@Nullable Duration duration) { this.ticksRemaining = duration == null ? null : duration.toMillis() / 50; } public void stop() { this.task.cancel(); SidebarMatchModule.this.blinkingGoals.remove(this.goal); renderSidebarDebounce(); } public boolean isDark() { return this.dark; } @Override public void run() { if(this.ticksRemaining != null) { this.ticksRemaining -= this.intervalTicks; if(this.ticksRemaining <= 0) { this.task.cancel(); SidebarMatchModule.this.blinkingGoals.remove(this.goal); } } this.dark = !this.dark; renderSidebarDebounce(); } } }