highlight player dot/spec on hover

This commit is contained in:
Robin Appelman 2024-12-22 15:54:14 +01:00
commit 3644dbda6e
7 changed files with 355 additions and 298 deletions

View file

@ -51,6 +51,7 @@ export const Analyser = (props: AnalyseProps) => {
const closeDialogs = () => {
setModalState(ModalState.Closed);
};
const [highlighted, setHighlighted] = createSignal<number>(0);
createEffect(() => {
const e = event();
@ -243,7 +244,10 @@ export const Analyser = (props: AnalyseProps) => {
projectiles={projectiles()}
header={props.header}
world={backgroundBoundaries}
scale={scale()}/>
scale={scale()}
onHover={setHighlighted}
highlighted={highlighted()}
/>
</MapContainer>
<AnalyseMenu sessionName={sessionName()}
onShare={() => {
@ -261,7 +265,9 @@ export const Analyser = (props: AnalyseProps) => {
inShared={inShared}
/>
<SpecHUD parser={parser} tick={tick()}
players={players()} events={events}/>
players={players()} events={events}
highlighted={highlighted()}
onHover={setHighlighted}/>
</div>
<div class="time-control"
title={timeTitle()}>

View file

@ -16,6 +16,8 @@ export interface MapRenderProps {
},
world: WorldBoundaries;
scale: number;
onHover: (userId: number) => void;
highlighted: number;
}
const map_root = document.querySelector('[data-maps]').getAttribute('data-maps');
@ -30,7 +32,10 @@ export function MapRender(props: MapRenderProps) {
style={{"background-image": background}}>
<For each={props.players}>{(player) =>
<Show when={player.health}>
<PlayerDot player={player} mapBoundary={props.world} targetSize={props.size} scale={props.scale}/>
<PlayerDot player={player} mapBoundary={props.world} targetSize={props.size} scale={props.scale}
onHover={props.onHover}
highlighted={props.highlighted === player.info.userId}
/>
</Show>
}</For>
<For each={props.buildings}>{(building) =>

View file

@ -71,7 +71,7 @@ export function KillFeedDestroyedItem(props: KillFeedDestroyedItemProps) {
return <li class="kill">
<PlayerNames players={[attacker, assister]}/>
<KillIcon kill={props.event}/>
<PlayerName player={victim}/><span className={teamMap[victim.team]}>({props.event.building_type})</span>
<PlayerName player={victim}/><span class={teamMap[victim.team]}>({props.event.building_type})</span>
</li>
}
@ -98,7 +98,7 @@ interface PlayerNameProps {
export function PlayerName(props: PlayerNameProps) {
return <Show when={props.player}>
<span className={"player " + teamMap[props.player.team]}>
<span class={"player " + teamMap[props.player.team]}>
{props.player.info.name}
</span>
</Show>
@ -111,7 +111,7 @@ interface PlayerNamesProps {
export function PlayerNames(props: PlayerNamesProps) {
return <For each={props.players}>{(player, i) => <>
<Show when={i() > 0 && player}>
<span className={teamMap[player.team]}>+</span>
<span class={teamMap[player.team]}>+</span>
</Show>
<PlayerName player={player}/>
</>}</For>

View file

@ -8,6 +8,8 @@ export interface PlayerProp {
height: number;
};
scale: number;
onHover: (userId: number) => void;
highlighted: boolean;
}
const healthMap = {
@ -51,12 +53,15 @@ export function Player(props: PlayerProp) {
const rotate = () => `rotate(${270 - props.player.angle})`;
return <g
onmouseover={() => props.onHover(props.player.info.userId)}
onmouseout={() => props.onHover(0)}
transform={transform()}>
<polygon points="-6,14 0, 16 6,14 0,24" fill="white"
opacity={imageOpacity()}
transform={rotate()}/>
<circle r={16} stroke-width={1.5} stroke="white" fill={teamColor()}
opacity={alpha()}/>
<circle r={16} stroke-width={props.highlighted ? 5 : 1.5} stroke="white" fill={teamColor()}
opacity={alpha()}
/>
{getClassImage(props.player, imageOpacity())}
</g>
}

View file

@ -2,146 +2,162 @@ import {PlayerState} from "../Data/Parser";
import {KillFeedItem} from "./KillFeed";
export interface PlayerSpecProps {
player: PlayerState;
player: PlayerState;
onHover: (userId: number) => void;
highlighted: boolean;
}
const healthMap = {
0: 100, //fallback
1: 125, //scout
2: 150, //sniper
3: 200, //soldier,
4: 175, //demoman,
5: 150, //medic,
6: 300, //heavy,
7: 175, //pyro
8: 125, //spy
9: 125, //engineer
0: 100, //fallback
1: 125, //scout
2: 150, //sniper
3: 200, //soldier,
4: 175, //demoman,
5: 150, //medic,
6: 300, //heavy,
7: 175, //pyro
8: 125, //spy
9: 125, //engineer
};
const classMap = {
1: "scout",
2: "sniper",
3: "soldier",
4: "demoman",
5: "medic",
6: "heavy",
7: "pyro",
8: "spy",
9: "engineer"
1: "scout",
2: "sniper",
3: "soldier",
4: "demoman",
5: "medic",
6: "heavy",
7: "pyro",
8: "spy",
9: "engineer"
};
const classSort = {
1: 1, //scout
3: 2, //soldier
7: 3, //pyro
4: 4, //demoman
6: 5, //heavy
9: 6, //engineer
5: 7, //medic
2: 8, //sniper
8: 9, //spy
1: 1, //scout
3: 2, //soldier
7: 3, //pyro
4: 4, //demoman
6: 5, //heavy
9: 6, //engineer
5: 7, //medic
2: 8, //sniper
8: 9, //spy
};
const teamMap = {
0: "other",
1: "spectator",
2: "red",
3: "blue",
0: "other",
1: "spectator",
2: "red",
3: "blue",
}
export interface PlayersSpecProps {
players: PlayerState[];
players: PlayerState[];
onHover: (userId: number) => void;
highlighted: number;
}
function sortPlayer(a, b) {
return classSort[a.playerClass] - classSort[b.playerClass];
return classSort[a.playerClass] - classSort[b.playerClass];
}
function filterPlayers(players: PlayerState[], team: number): PlayerState[] {
const filtered = players.filter((player) => player.team === team);
filtered.sort(sortPlayer);
return filtered;
const filtered = players.filter((player) => player.team === team);
filtered.sort(sortPlayer);
return filtered;
}
function medics(players: PlayerState[]): PlayerState[] {
return players.filter(player => player.playerClass === 5);
return players.filter(player => player.playerClass === 5);
}
export function PlayersSpec(props: PlayersSpecProps) {
const redPlayers = () => filterPlayers(props.players, 2);
const bluePlayers = () => filterPlayers(props.players, 3);
const redMedics = () => medics(redPlayers());
const blueMedics = () => medics(bluePlayers());
const redPlayers = () => filterPlayers(props.players, 2);
const bluePlayers = () => filterPlayers(props.players, 3);
const redMedics = () => medics(redPlayers());
const blueMedics = () => medics(bluePlayers());
return (<div>
<div class="redSpecHolder">
<For each={redPlayers()}>{(player) =>
<PlayerSpec player={player}/>
}</For>
<For each={redMedics()}>{(player) =>
<UberSpec
team={teamMap[player.team]}
chargeLevel={player.charge}
isDeath={player.health < 1}
/>
}</For>
</div>
<div class="blueSpecHolder">
<For each={bluePlayers()}>{(player) =>
<PlayerSpec player={player}/>
}</For>
<For each={blueMedics()}>{(player) =>
<UberSpec
team={teamMap[player.team]}
chargeLevel={player.charge}
isDeath={player.health < 1}
/>
}</For>
</div>
</div>);
return (<div>
<div class="redSpecHolder">
<For each={redPlayers()}>{(player: PlayerState) =>
<PlayerSpec player={player} highlighted={player.info.userId == props.highlighted}
onHover={props.onHover}/>
}</For>
<For each={redMedics()}>{(player) =>
<UberSpec
team={teamMap[player.team]}
chargeLevel={player.charge}
isDeath={player.health < 1}
/>
}</For>
</div>
<div class="blueSpecHolder">
<For each={bluePlayers()}>{(player) =>
<PlayerSpec player={player} highlighted={player.info.userId == props.highlighted}
onHover={props.onHover}/>
}</For>
<For each={blueMedics()}>{(player) =>
<UberSpec
team={teamMap[player.team]}
chargeLevel={player.charge}
isDeath={player.health < 1}
/>
}</For>
</div>
</div>);
}
export function PlayerSpec({player}: PlayerSpecProps) {
const healthPercent = Math.min(100, player.health / healthMap[player.playerClass] * 100);
const healthStatusClass = (player.health > healthMap[player.playerClass]) ? 'overhealed' : (player.health <= 0 ? 'dead' : '');
export function PlayerSpec(props: PlayerSpecProps) {
const healthPercent = () => Math.min(100, props.player.health / healthMap[props.player.playerClass] * 100);
const healthStatusClass = () => (props.player.health > healthMap[props.player.playerClass]) ? 'overhealed' : (props.player.health <= 0 ? 'dead' : '');
return (
<div
class={"playerspec " + teamMap[player.team] + " webp " + healthStatusClass}>
{getPlayerIcon(player)}
<div class="health-container">
<div class="healthbar"
style={{width: healthPercent + '%'}}/>
<span class="player-name">{player.info.name}</span>
<span class="health">{player.health}</span>
</div>
</div>
);
return (
<div
onmouseover={() => props.onHover(props.player.info.userId)}
onmouseout={() => props.onHover(0)}
classList={{
"playerspec": true,
[teamMap[props.player.team]]: true,
"webp": true,
[healthStatusClass()]: true,
highlighted: props.highlighted,
}}>
{getPlayerIcon(props.player)}
<div class="health-container">
<div class="healthbar"
style={{width: healthPercent() + '%'}}/>
<span class="player-name">{props.player.info.name}</span>
<span class="health">{props.player.health}</span>
</div>
</div>
);
}
function getPlayerIcon(player: PlayerState) {
if (classMap[player.playerClass]) {
return <div class={classMap[player.playerClass] + " class-icon"}/>
} else {
return <div class={"class-icon"}/>
}
if (classMap[player.playerClass]) {
return <div class={classMap[player.playerClass] + " class-icon"}/>
} else {
return <div class={"class-icon"}/>
}
}
export interface UberSpecProps {
chargeLevel: number;
team: string;
isDeath: boolean;
chargeLevel: number;
team: string;
isDeath: boolean;
}
export function UberSpec({chargeLevel, team, isDeath}: UberSpecProps) {
const healthStatusClass = (isDeath) ? 'dead' : '';
return (
<div class={`playerspec uber ${team} ${healthStatusClass}`}>
<div class={"uber class-icon"}/>
<div class="health-container">
<div class="healthbar"
style={{width: chargeLevel + '%'}}/>
<span class="player-name">Charge</span>
<span class="health">{Math.round(chargeLevel)}</span>
</div>
</div>
);
const healthStatusClass = (isDeath) ? 'dead' : '';
return (
<div class={`playerspec uber ${team} ${healthStatusClass}`}>
<div class={"uber class-icon"}/>
<div class="health-container">
<div class="healthbar"
style={{width: chargeLevel + '%'}}/>
<span class="player-name">Charge</span>
<span class="health">{Math.round(chargeLevel)}</span>
</div>
</div>
);
}

View file

@ -7,13 +7,15 @@ export interface SpecHUDProps {
tick: number;
parser: AsyncParser;
players: PlayerState[];
events: Event[]
events: Event[];
onHover: (userId: number) => void;
highlighted: number | null;
}
export function SpecHUD(props: SpecHUDProps) {
return (<div class="spechud">
<KillFeed tick={props.tick} events={props.events} players={props.players}/>
<PlayersSpec players={props.players}/>
<PlayersSpec players={props.players} onHover={props.onHover} highlighted={props.highlighted}/>
</div>)
}

View file

@ -1,218 +1,241 @@
.blueSpecHolder {
position: absolute;
left: 0;
top: 50%;
transform: translate(0, -50%);
position: absolute;
left: 0;
top: 50%;
transform: translate(0, -50%);
}
.redSpecHolder {
position: absolute;
right: 0;
top: 50%;
transform: translate(0, -50%);
position: absolute;
right: 0;
top: 50%;
transform: translate(0, -50%);
}
.playerspec {
background-color: black;
color: white;
height: 42px;
width: 200px;
position: relative;
font-family: sans-serif;
margin-bottom: 2px;
user-select: none;
&.uber {
height: 28px;
}
& .class-icon, .steam-avatar {
width: 42px;
background-color: black;
color: white;
height: 42px;
display: inline-block;
position: absolute;
top: 0;
left: 0;
background-position: top left;
background-size: 100% 100%;
width: 200px;
position: relative;
font-family: sans-serif;
margin-bottom: 2px;
user-select: none;
&.uber {
height: 28px;
background-size: 28px 28px;
background-repeat: no-repeat;
background-position: 50% 50%;
}
}
& .player-name {
display: inline-block;
position: relative;
padding: 0 5px;
white-space: nowrap;
width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
& .health-container {
display: inline-block;
position: absolute;
left: 42px;
top: 0;
height: 28px;
width: calc(100% - 42px);
line-height: 28px;
font-weight: bold;
& .health {
position: relative;
float: right;
padding: 0 5px;
height: 28px;
}
& .healthbar {
position: absolute;
top: 0;
left: 0;
height: 28px;
}
}
& .class-icon, .steam-avatar {
width: 42px;
height: 42px;
display: inline-block;
position: absolute;
top: 0;
left: 0;
background-position: top left;
background-size: 100% 100%;
&.red {
& .health-container {
background-color: #a75d50aa;
}
& .healthbar {
background-color: #a75d50;
}
& .class-icon.scout {
background-image: url('inline://images/class_portraits/Icon_scout.webp');
}
& .class-icon.soldier {
background-image: url('inline://images/class_portraits/Icon_soldier.webp');
}
& .class-icon.pyro {
background-image: url('inline://images/class_portraits/Icon_pyro.webp');
}
& .class-icon.demoman {
background-image: url('inline://images/class_portraits/Icon_demoman.webp');
}
& .class-icon.engineer {
background-image: url('inline://images/class_portraits/Icon_engineer.webp');
}
& .class-icon.heavy {
background-image: url('inline://images/class_portraits/Icon_heavy.webp');
}
& .class-icon.medic {
background-image: url('inline://images/class_portraits/Icon_medic.webp');
}
& .class-icon.sniper {
background-image: url('inline://images/class_portraits/Icon_sniper.webp');
}
& .class-icon.spy{
background-image: url('inline://images/class_portraits/Icon_spy.webp');
}
& .class-icon.uber {
background-image: url('inline://images/charge_red.svg');
}
& .class-icon, & .steam-avatar {
right: 0;
left: auto;
}
& .health-container {
right: 42px;
left: auto;
}
& .health {
float: left;
&.uber {
height: 28px;
background-size: 28px 28px;
background-repeat: no-repeat;
background-position: 50% 50%;
}
}
& .player-name {
float: right;
direction: ltr;
text-align: right;
}
}
&.blue {
& .health-container {
background-color: #5b818faa;
}
& .healthbar {
background-color: #5b818f;
}
& .class-icon.scout {
background-image: url('inline://images/class_portraits/Icon_scout_blue.webp');
}
& .class-icon.soldier {
background-image: url('inline://images/class_portraits/Icon_soldier_blue.webp');
}
& .class-icon.pyro {
background-image: url('inline://images/class_portraits/Icon_pyro_blue.webp');
}
& .class-icon.demoman {
background-image: url('inline://images/class_portraits/Icon_demoman_blue.webp');
}
& .class-icon.engineer {
background-image: url('inline://images/class_portraits/Icon_engineer_blue.webp');
}
& .class-icon.heavy {
background-image: url('inline://images/class_portraits/Icon_heavy_blue.webp');
}
& .class-icon.medic {
background-image: url('inline://images/class_portraits/Icon_medic_blue.webp');
}
& .class-icon.sniper {
background-image: url('inline://images/class_portraits/Icon_sniper_blue.webp');
}
& .class-icon.spy {
background-image: url('inline://images/class_portraits/Icon_spy_blue.webp');
}
& .class-icon.uber {
background-image: url('inline://images/charge_blue.svg');
}
}
&.overhealed {
& .health {
color: #79d297;
}
& .health:after {
position: absolute;
top: 21px;
right: 0;
padding: 0 5px;
font-size: 10px;
font-weight: bold;
content: 'OVERHEALED'
}
&.red .health:after {
position: absolute;
top: 21px;
left: 0;
right: auto;
}
}
&.dead {
& .healthbar, & .health {
display: none;
display: inline-block;
position: relative;
padding: 0 5px;
white-space: nowrap;
width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
& .health-container {
background-color: transparent;
display: inline-block;
position: absolute;
left: 42px;
top: 0;
height: 28px;
width: calc(100% - 42px);
line-height: 28px;
font-weight: bold;
& .health {
position: relative;
float: right;
padding: 0 5px;
}
& .healthbar {
position: absolute;
top: 0;
left: 0;
height: 28px;
}
}
& .class-icon {
opacity: 0.5;
&.red {
& .health-container {
background-color: #a75d50aa;
}
& .healthbar {
background-color: #a75d50;
}
& .class-icon.scout {
background-image: url('inline://images/class_portraits/Icon_scout.webp');
}
& .class-icon.soldier {
background-image: url('inline://images/class_portraits/Icon_soldier.webp');
}
& .class-icon.pyro {
background-image: url('inline://images/class_portraits/Icon_pyro.webp');
}
& .class-icon.demoman {
background-image: url('inline://images/class_portraits/Icon_demoman.webp');
}
& .class-icon.engineer {
background-image: url('inline://images/class_portraits/Icon_engineer.webp');
}
& .class-icon.heavy {
background-image: url('inline://images/class_portraits/Icon_heavy.webp');
}
& .class-icon.medic {
background-image: url('inline://images/class_portraits/Icon_medic.webp');
}
& .class-icon.sniper {
background-image: url('inline://images/class_portraits/Icon_sniper.webp');
}
& .class-icon.spy {
background-image: url('inline://images/class_portraits/Icon_spy.webp');
}
& .class-icon.uber {
background-image: url('inline://images/charge_red.svg');
}
& .class-icon, & .steam-avatar {
right: 0;
left: auto;
}
& .health-container {
right: 42px;
left: auto;
}
& .health {
float: left;
}
& .player-name {
float: right;
direction: ltr;
text-align: right;
}
}
&.blue {
& .health-container {
background-color: #5b818faa;
}
& .healthbar {
background-color: #5b818f;
}
& .class-icon.scout {
background-image: url('inline://images/class_portraits/Icon_scout_blue.webp');
}
& .class-icon.soldier {
background-image: url('inline://images/class_portraits/Icon_soldier_blue.webp');
}
& .class-icon.pyro {
background-image: url('inline://images/class_portraits/Icon_pyro_blue.webp');
}
& .class-icon.demoman {
background-image: url('inline://images/class_portraits/Icon_demoman_blue.webp');
}
& .class-icon.engineer {
background-image: url('inline://images/class_portraits/Icon_engineer_blue.webp');
}
& .class-icon.heavy {
background-image: url('inline://images/class_portraits/Icon_heavy_blue.webp');
}
& .class-icon.medic {
background-image: url('inline://images/class_portraits/Icon_medic_blue.webp');
}
& .class-icon.sniper {
background-image: url('inline://images/class_portraits/Icon_sniper_blue.webp');
}
& .class-icon.spy {
background-image: url('inline://images/class_portraits/Icon_spy_blue.webp');
}
& .class-icon.uber {
background-image: url('inline://images/charge_blue.svg');
}
}
&.highlighted:not(.dead) {
outline: white 2px solid;
}
&.overhealed {
& .health {
color: #79d297;
}
& .health:after {
position: absolute;
top: 21px;
right: 0;
padding: 0 5px;
font-size: 10px;
font-weight: bold;
content: 'OVERHEALED'
}
&.red .health:after {
position: absolute;
top: 21px;
left: 0;
right: auto;
}
}
&.dead {
& .healthbar, & .health {
display: none;
}
& .health-container {
background-color: transparent;
}
& .class-icon {
opacity: 0.5;
}
}
}
}