ProjectAres/PGM/src/main/java/tc/oc/pgm/scoreboard/SidebarMatchModule.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();
}
}
}