From 3644dbda6e13b5692616ff4d27e01969dc3abed9 Mon Sep 17 00:00:00 2001 From: Robin Appelman <robin@icewind.nl> Date: Sun, 22 Dec 2024 15:54:14 +0100 Subject: [PATCH] highlight player dot/spec on hover --- script/viewer/Analyse/Analyser.tsx | 10 +- script/viewer/Analyse/MapRender.tsx | 7 +- script/viewer/Analyse/Render/KillFeed.tsx | 6 +- script/viewer/Analyse/Render/Player.tsx | 9 +- script/viewer/Analyse/Render/PlayerSpec.tsx | 228 ++++++------ script/viewer/Analyse/Render/SpecHUD.tsx | 6 +- style/pages/viewer/PlayerSpec.css | 387 +++++++++++--------- 7 files changed, 355 insertions(+), 298 deletions(-) diff --git a/script/viewer/Analyse/Analyser.tsx b/script/viewer/Analyse/Analyser.tsx index 9802757..cce9bf9 100644 --- a/script/viewer/Analyse/Analyser.tsx +++ b/script/viewer/Analyse/Analyser.tsx @@ -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()}> diff --git a/script/viewer/Analyse/MapRender.tsx b/script/viewer/Analyse/MapRender.tsx index 5058e1f..fafc8f8 100644 --- a/script/viewer/Analyse/MapRender.tsx +++ b/script/viewer/Analyse/MapRender.tsx @@ -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) => diff --git a/script/viewer/Analyse/Render/KillFeed.tsx b/script/viewer/Analyse/Render/KillFeed.tsx index dc41855..617c8c6 100644 --- a/script/viewer/Analyse/Render/KillFeed.tsx +++ b/script/viewer/Analyse/Render/KillFeed.tsx @@ -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> diff --git a/script/viewer/Analyse/Render/Player.tsx b/script/viewer/Analyse/Render/Player.tsx index 672b594..4f39d3c 100644 --- a/script/viewer/Analyse/Render/Player.tsx +++ b/script/viewer/Analyse/Render/Player.tsx @@ -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> } diff --git a/script/viewer/Analyse/Render/PlayerSpec.tsx b/script/viewer/Analyse/Render/PlayerSpec.tsx index 7434154..029d2e2 100644 --- a/script/viewer/Analyse/Render/PlayerSpec.tsx +++ b/script/viewer/Analyse/Render/PlayerSpec.tsx @@ -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()); - - 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>); + 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: 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' : ''); - - 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> - ); +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 + 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> + ); } diff --git a/script/viewer/Analyse/Render/SpecHUD.tsx b/script/viewer/Analyse/Render/SpecHUD.tsx index b0d3f15..f951f48 100644 --- a/script/viewer/Analyse/Render/SpecHUD.tsx +++ b/script/viewer/Analyse/Render/SpecHUD.tsx @@ -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>) } diff --git a/style/pages/viewer/PlayerSpec.css b/style/pages/viewer/PlayerSpec.css index 4048a0c..6926e78 100644 --- a/style/pages/viewer/PlayerSpec.css +++ b/style/pages/viewer/PlayerSpec.css @@ -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%; + height: 28px; } - } - & .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; + & .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%; + + &.uber { + height: 28px; + background-size: 28px 28px; + background-repeat: no-repeat; + background-position: 50% 50%; + } } - & .healthbar { - position: absolute; - top: 0; - left: 0; - height: 28px; + & .player-name { + display: inline-block; + position: relative; + padding: 0 5px; + white-space: nowrap; + width: 120px; + overflow: hidden; + text-overflow: ellipsis; } - } - &.red { & .health-container { - background-color: #a75d50aa; - } + 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; + } + } + + &.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'); + } - & .healthbar { - background-color: #a75d50; - } + & .class-icon.demoman { + background-image: url('inline://images/class_portraits/Icon_demoman_blue.webp'); + } - & .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.engineer { + background-image: url('inline://images/class_portraits/Icon_engineer_blue.webp'); + } - & .class-icon, & .steam-avatar { - right: 0; - left: auto; - } + & .class-icon.heavy { + background-image: url('inline://images/class_portraits/Icon_heavy_blue.webp'); + } - & .health-container { - right: 42px; - left: auto; - } + & .class-icon.medic { + background-image: url('inline://images/class_portraits/Icon_medic_blue.webp'); + } - & .health { - float: left; - } + & .class-icon.sniper { + background-image: url('inline://images/class_portraits/Icon_sniper_blue.webp'); + } - & .player-name { - float: right; - direction: ltr; - text-align: right; - } - } + & .class-icon.spy { + background-image: url('inline://images/class_portraits/Icon_spy_blue.webp'); + } - &.blue { - & .health-container { - background-color: #5b818faa; + & .class-icon.uber { + background-image: url('inline://images/charge_blue.svg'); + } } - & .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'); + &.highlighted:not(.dead) { + outline: white 2px solid; } - & .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; - } + &.overhealed { + & .health { + color: #79d297; + } - & .health:after { - position: absolute; - top: 21px; - right: 0; - padding: 0 5px; - font-size: 10px; - font-weight: bold; - content: 'OVERHEALED' - } + & .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; + &.red .health:after { + position: absolute; + top: 21px; + left: 0; + right: auto; + } } - } - &.dead { - & .healthbar, & .health { - display: none; - } + &.dead { + & .healthbar, & .health { + display: none; + } - & .health-container { - background-color: transparent; - } + & .health-container { + background-color: transparent; + } - & .class-icon { - opacity: 0.5; + & .class-icon { + opacity: 0.5; + } } - } }