Skip to content

Commit

Permalink
Merge pull request #36 from EboMike/cleanup
Browse files Browse the repository at this point in the history
Added other players to the score chart on SM5 score cards.
  • Loading branch information
spookybear0 authored Mar 30, 2024
2 parents 8eb441d + aa64f4d commit e7ac7a8
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 54 deletions.
27 changes: 10 additions & 17 deletions assets/html/game/scorecard_sm5.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,22 +121,12 @@
justify-content: center;
}

.fire_team_header, .earth_team_header {
.team-header {
font-size: 20px;
float: none;
margin-bottom: 16px;
}

.fire_team_header {
color: orangered;
font-size: 20px;
}

.earth_team_header {
color: greenyellow;
font-size: 20px;
}

.scorecard_main_stats {
width: 45%;
float: left;
Expand Down Expand Up @@ -281,7 +271,7 @@ <h2 style="font-size: 20px;">&nbsp;{{entity_start.name}}</h2>
<div class="scorecard_player_stats">
{% for team in teams %}
<div class="scorecard_team_stats">
<h2 class="{{ team.class_name }}_team_header">{{ team.name }} ({{ team.score }})</h2>
<h2 class="{{ team.class_name }} team-header">{{ team.name }} ({{ team.score }})</h2>
<table class="scorecard_player_stats_table">
<tr>
<th>Player</th>
Expand Down Expand Up @@ -406,15 +396,18 @@ <h2 class="{{ team.class_name }}_team_header">{{ team.name }} ({{ team.score }})
data: {
labels: {{score_chart_labels}},
datasets: [
{% for player_score_chart in score_chart_data %}
{
label: "{{entity_start.name}}'s Score",
data: {{score_chart_data}},
borderColor: "orangered",
label: "{{ player_score_chart.label }}",
data: {{ player_score_chart.data }},
borderColor: "{{ player_score_chart.color }}",
borderWidth: {{ player_score_chart.borderWidth }},
fill: false,
tension: 0.4,
pointRadius: 0,
pointHitRadius: 10
}
pointHitRadius: 30
}{{ "," if not loop.last }}
{% endfor %}
]
},
options: {
Expand Down
75 changes: 70 additions & 5 deletions db/types.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,51 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Optional, List
from enum import Enum, IntEnum


@dataclass
class RgbColor:
"""An RGB value, with each component having a value between 0 and 255."""
red: int
green: int
blue: int

@property
def rgb_value(self) -> str:
"""Returns the color as an RGB string to plug into HTML or CSS."""
return "#%02x%02x%02x" % (self.red, self.green, self.blue)

def add(self, other: "RgbColor") -> "RgbColor":
"""Returns a new RgbColor() that is the addition of this and the other one.
Components are clamped at their max value."""
return RgbColor(
red=self._add_values(self.red, other.red),
green=self._add_values(self.green, other.green),
blue=self._add_values(self.blue, other.blue)
)

def multiply(self, multiplier: int) -> "RgbColor":
"""Returns a new RgbColor() that is each component multiplied by a value.
Components are clamped at their max value."""
return RgbColor(
red=self._add_values(self.red, multiplier),
green=self._add_values(self.green, multiplier),
blue=self._add_values(self.blue, multiplier)
)

@staticmethod
def _add_values(value1: int, value2: int) -> int:
return min(value1 + value2, 255)

@staticmethod
def _multiply_value(value1: int, value2: int) -> int:
return min(value1 * value2, 255)


@dataclass
class _TeamDefinition:
"""Descriptor for a team.
Expand All @@ -13,6 +56,7 @@ class _TeamDefinition:
element: str
css_class: str
css_color_name: str
dim_color: RgbColor

def __eq__(self, color: str) -> bool:
return self.color == color
Expand All @@ -23,10 +67,10 @@ def __len__(self):

def __str__(self):
return self.color

def __repr__(self):
return f'"{self.color}"'

def __json__(self):
return f'"{self.color}"'

Expand All @@ -35,9 +79,12 @@ def __hash__(self):


class Team(Enum):
RED = _TeamDefinition(color="red", element="Fire", css_class="fire-team", css_color_name="orangered")
GREEN = _TeamDefinition(color="green", element="Earth", css_class="earth-team", css_color_name="greenyellow")
BLUE = _TeamDefinition(color="blue", element="Ice", css_class="ice-team", css_color_name="#0096FF")
RED = _TeamDefinition(color="red", element="Fire", css_class="fire-team", css_color_name="orangered",
dim_color=RgbColor(red=68, green=17, blue=0))
GREEN = _TeamDefinition(color="green", element="Earth", css_class="earth-team", css_color_name="greenyellow",
dim_color=RgbColor(red=43, green=60, blue=12))
BLUE = _TeamDefinition(color="blue", element="Ice", css_class="ice-team", css_color_name="#0096FF",
dim_color=RgbColor(red=0, green=37, blue=68))

def __call__(cls, value, *args, **kw):
# Tortoise looks up values by the lower-case color name.
Expand All @@ -48,20 +95,29 @@ def __call__(cls, value, *args, **kw):
return super().__call__(value, *args, **kw)

def standardize(self) -> str:
"""The color name starting in upper case, like "Red" or "Blue"."""
return self.value.color.capitalize()

@property
def element(self) -> str:
"""The element, like "Fire" or "Ice"."""
return self.value.element

@property
def css_class(self) -> str:
"""CSS class to use to show text using the color of this team."""
return self.value.css_class

@property
def css_color_name(self) -> str:
"""CSS color to use for this team, could be a RGB HEX value or a CSS color value."""
return self.value.css_color_name

@property
def dim_color(self) -> RgbColor:
"""Color to use for this team at a darker brightness, good for line graphs showing peripheral data."""
return self.value.dim_color


# Mapping of opposing teams in SM5 games.
SM5_ENEMY_TEAM = {
Expand Down Expand Up @@ -210,3 +266,12 @@ class PieChartData:
labels: List[str]
colors: List[str]
data: List[int]


@dataclass
class LineChartData:
"""Data sent to a frontend template to display a line chart dataset."""
label: str
color: str
data: List[int]
borderWidth: int = 3
94 changes: 63 additions & 31 deletions handlers/game/scorecard.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
from itertools import chain

from numpy import arange
from sanic import Request

from helpers.formattinghelper import create_ratio_string
from helpers.gamehelper import SM5_STATE_LABEL_MAP, SM5_STATE_COLORS, get_players_from_team
from helpers.gamehelper import SM5_STATE_LABEL_MAP, SM5_STATE_COLORS, get_players_from_team, get_team_rosters
from shared import app
from typing import List
from utils import render_template
from db.types import IntRole, EventType, PlayerStateDetailType, Team
from db.types import IntRole, Team, LineChartData, RgbColor
from db.sm5 import SM5Game, SM5Stats
from db.game import EntityEnds, EntityStarts
from db.laserball import LaserballGame, LaserballStats
from helpers.statshelper import sentry_trace, _millis_to_time, count_zaps, count_missiles, count_blocks, \
from helpers.statshelper import sentry_trace, millis_to_time, count_zaps, count_missiles, count_blocks, \
get_player_state_distribution, get_sm5_score_components, get_sm5_kd_ratio, get_sm5_single_player_score_graph_data, \
get_sm5_player_alive_times, get_player_state_distribution_pie_chart
from sanic import exceptions


# Modifiers for the score card colors of other players. One of these will be applied
# to the color so it's ever so slightly different.
_RGB_MODIFIERS = [RgbColor(0, 0, 16), RgbColor(0, 16, 0), RgbColor(16, 0, 0)]


def _chart_values(values: list[int]) -> str:
"""Creates a string to be used in JavaScript for a list of integers."""
return "[%s]" % ", ".join([str(value) for value in values])


def _get_score_chart_color(player_id: int, scorecard_player_id: int, team: Team, index: int) -> str:
# If it's the player this score card is for, use the proper color:
if scorecard_player_id == player_id:
return team.css_color_name

# Use the dim color, but slightly change it based on index so they don't all look the
# same.
modifier = _RGB_MODIFIERS[index % 3].multiply(int(index / 3))
return team.dim_color.add(modifier).rgb_value


def _calc_ratio(numerator: int, divisor: int) -> float:
return float(numerator) / float(divisor) if divisor else 0.0
Expand All @@ -37,7 +54,7 @@ def _chart_strings(values: list[str]) -> str:
@sentry_trace
async def scorecard(request: Request, type: str, id: int, entity_end_id: int) -> str:
if type == "sm5":
game = await SM5Game.filter(id=id).prefetch_related("entity_starts").first()
game = await SM5Game.filter(id=id).prefetch_related("entity_starts", "entity_ends").first()

if not game:
raise exceptions.NotFound("Game not found")
Expand Down Expand Up @@ -85,20 +102,27 @@ async def scorecard(request: Request, type: str, id: int, entity_end_id: int) ->

score_composition.sort(key=lambda x: x["score"], reverse=True)

state_distribution = await get_player_state_distribution(entity_start, entity_end, game.player_states, game.events,
state_distribution = await get_player_state_distribution(entity_start, entity_end, game.player_states,
game.events,
SM5_STATE_LABEL_MAP)

state_distribution_labels = list(state_distribution.keys())
state_distribution_values = list(state_distribution.values())
state_distribution_colors = [SM5_STATE_COLORS[state] for state in state_distribution.keys()]

entity_starts: List[EntityStarts] = game.entity_starts
team_rosters = await get_team_rosters(game.entity_starts, game.entity_ends)

player_teams = {
player.entity_start.id: team for team, player_list in team_rosters.items() for player in player_list
}

# Get a flat list of all players across all teams.
player_entities = [
player for player in list(entity_starts) if player.type == "player"
player.entity_start for player in list(chain.from_iterable(team_rosters.values()))
]

player_entity_ends = {
player.id: await EntityEnds.filter(entity=player.id).first() for player in player_entities
player.entity_start.id: player.entity_end for player in list(chain.from_iterable(team_rosters.values()))
}

player_sm5_stats = {
Expand All @@ -109,49 +133,57 @@ async def scorecard(request: Request, type: str, id: int, entity_end_id: int) ->

for player in player_entities:
if player.id not in player_sm5_stats or player_sm5_stats.get(player.id) is None:
raise exceptions.NotFound("Player stats not found")
raise exceptions.NotFound("Player stats for entity %d not found" % player.id)

all_players = ([
{
"name": player.name,
"team": (await player.team).index,
"entity_start_id": player.id,
"entity_end_id": player_entity_ends[player.id].id,
"team": player_teams[player.id],
"role": player.role,
"css_class": "player%s%s" % (" active_player" if player.id == entity_start.id else "",
" eliminated_player" if player_sm5_stats[player.id] or player_sm5_stats[player.id].lives_left == 0 else ""),
" eliminated_player" if player_sm5_stats[player.id] or player_sm5_stats[
player.id].lives_left == 0 else ""),
"score": player_entity_ends[player.id].score,
"lives_left": player_sm5_stats[player.id].lives_left if player.id in player_sm5_stats else "",
"time_in_game_values": get_sm5_player_alive_times(game_duration, player_entity_ends[player.id]),
"kd_ratio": ("%.2f" % get_sm5_kd_ratio(player_sm5_stats[player.id])) if player.id in player_sm5_stats else "",
"kd_ratio": ("%.2f" % get_sm5_kd_ratio(
player_sm5_stats[player.id])) if player.id in player_sm5_stats else "",
"mvp_points": "%.2f" % await player_sm5_stats[player.id].mvp_points(),
"you_zapped": await count_zaps(game, entity_start.entity_id, player.entity_id),
"zapped_you": await count_zaps(game, player.entity_id, entity_start.entity_id),
"hit_ratio": create_ratio_string(_calc_ratio(await count_zaps(game, entity_start.entity_id, player.entity_id), await count_zaps(game, player.entity_id, entity_start.entity_id))),
"hit_ratio": create_ratio_string(
_calc_ratio(await count_zaps(game, entity_start.entity_id, player.entity_id),
await count_zaps(game, player.entity_id, entity_start.entity_id))),
"you_missiled": await count_missiles(game, entity_start.entity_id, player.entity_id),
"missiled_you": await count_missiles(game, player.entity_id, entity_start.entity_id),
"state_distribution": get_player_state_distribution_pie_chart(await get_player_state_distribution(player, player_entity_ends[player.id],
game.player_states, game.events, SM5_STATE_LABEL_MAP), SM5_STATE_COLORS)
"state_distribution": get_player_state_distribution_pie_chart(
await get_player_state_distribution(player, player_entity_ends[player.id],
game.player_states, game.events, SM5_STATE_LABEL_MAP),
SM5_STATE_COLORS)
} for player in player_entities
])
all_players.sort(key=lambda x: x["score"], reverse=True)

teams = [
{
"name": "Earth Team",
"class_name": "earth",
"score": await game.get_team_score(Team.GREEN),
"players": get_players_from_team(all_players, 1)
},
{
"name": "Fire Team",
"class_name": "fire",
"score": await game.get_team_score(Team.RED),
"players": get_players_from_team(all_players, 0)
},
"name": f"{team.element} Team",
"class_name": team.css_class,
"score": await game.get_team_score(team),
"players": [player for player in all_players if player["team"] == team]
}
for team in team_rosters.keys()]

score_chart_data = [
LineChartData(
label=player["name"],
color=_get_score_chart_color(player["entity_start_id"], entity_start.id, player["team"], index),
data=await get_sm5_single_player_score_graph_data(game, player["entity_start_id"]),
borderWidth=6 if entity_start.id == player["entity_start_id"] else 3
) for index, player in enumerate(all_players)
]

score_chart_data = await get_sm5_single_player_score_graph_data(game, entity_start.id)

return await render_template(
request,
"game/scorecard_sm5.html",
Expand All @@ -166,7 +198,7 @@ async def scorecard(request: Request, type: str, id: int, entity_end_id: int) ->
state_distribution_labels=_chart_strings(state_distribution_labels),
state_distribution_values=_chart_values(state_distribution_values),
state_distribution_colors=_chart_strings(state_distribution_colors),
score_chart_labels=[t for t in arange(0, 900000//1000//60+0.5, 0.5)],
score_chart_labels=[t for t in arange(0, 900000 // 1000 // 60 + 0.5, 0.5)],
score_chart_data=score_chart_data
)

Expand Down Expand Up @@ -195,7 +227,7 @@ async def scorecard(request: Request, type: str, id: int, entity_end_id: int) ->
"Score": stats.score,
"Shots fired": stats.shots_fired,
"Accuracy": "%.2f%%" % (accuracy * 100),
"Possession": _millis_to_time(possession_times.get(entity_start.entity_id)),
"Possession": millis_to_time(possession_times.get(entity_start.entity_id)),
"Goals": stats.goals,
"Assists": stats.assists,
"Passes": stats.passes,
Expand All @@ -219,7 +251,7 @@ async def scorecard(request: Request, type: str, id: int, entity_end_id: int) ->
"team": (await player.team).index,
"entity_end_id": (await EntityEnds.filter(entity=player.id).first()).id,
"score": player_stats[player.id].score,
"ball_possession": _millis_to_time(possession_times.get(player.entity_id, 0)),
"ball_possession": millis_to_time(possession_times.get(player.entity_id, 0)),
"you_blocked": await count_blocks(game, entity_start.entity_id, player.entity_id),
"blocked_you": await count_blocks(game, player.entity_id, entity_start.entity_id),
"mvp_points": "%.2f" % player_stats[player.id].mvp_points,
Expand Down
2 changes: 1 addition & 1 deletion helpers/statshelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
_EVENT_LATENCY_THRESHOLD_MILLIS = 50


def _millis_to_time(milliseconds: Optional[int]) -> str:
def millis_to_time(milliseconds: Optional[int]) -> str:
"""Converts milliseconds into an MM:SS string."""
if milliseconds is None:
return "00:00"
Expand Down

0 comments on commit e7ac7a8

Please sign in to comment.