548 lines
20 KiB
Java
548 lines
20 KiB
Java
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();
|
|
}
|
|
}
|
|
}
|