kill search

This commit is contained in:
Robin Appelman 2024-12-07 00:11:41 +01:00
commit 72c4b6ee08
10 changed files with 413 additions and 117 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,008 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -15,6 +15,8 @@ import {getMapBoundaries} from "./MapBoundries";
import {createEffect, createSignal, untrack} from "solid-js"; import {createEffect, createSignal, untrack} from "solid-js";
import {Session, StateUpdate} from "./Session"; import {Session, StateUpdate} from "./Session";
import {DemoHead} from "../../header"; import {DemoHead} from "../../header";
import {EventSearch} from "./EventSearch";
import {Event} from "./Data/Parser";
export interface AnalyseProps { export interface AnalyseProps {
header: DemoHead; header: DemoHead;
@ -37,10 +39,13 @@ export const Analyser = (props: AnalyseProps) => {
const [clients, setClients] = createSignal<number>(0); const [clients, setClients] = createSignal<number>(0);
const [helpOpen, setHelpOpen] = createSignal<boolean>(false); const [helpOpen, setHelpOpen] = createSignal<boolean>(false);
const [gotoOpen, setGotoOpen] = createSignal<boolean>(false); const [gotoOpen, setGotoOpen] = createSignal<boolean>(false);
const [searchOpen, setSearchOpen] = createSignal<boolean>(false);
const [search, setSearch] = createSignal<string>('');
const [gotoInput, setGotoInput] = createSignal<number>(0); const [gotoInput, setGotoInput] = createSignal<number>(0);
const closeDialogs = () => { const closeDialogs = () => {
setHelpOpen(false); setHelpOpen(false);
setGotoOpen(false); setGotoOpen(false);
setSearchOpen(false);
}; };
createEffect(() => { createEffect(() => {
@ -48,34 +53,45 @@ export const Analyser = (props: AnalyseProps) => {
untrack(() => { untrack(() => {
if (e) { if (e) {
if (e.key === '.') { const dialogOpen = searchOpen() | gotoOpen();
seek(1); if (!dialogOpen) {
e.preventDefault(); if (e.key === '.') {
} seek(1);
if (e.key === ',') { e.preventDefault();
seek(-1); }
e.preventDefault(); if (e.key === ',') {
} seek(-1);
if (e.key === 'ArrowRight') { e.preventDefault();
seek(15); }
e.preventDefault(); if (e.key === 'ArrowRight') {
} seek(15);
if (e.key === 'ArrowLeft') { e.preventDefault();
seek(-15); }
e.preventDefault(); if (e.key === 'ArrowLeft') {
} seek(-15);
if (e.key === ' ') { e.preventDefault();
togglePlay(); }
e.preventDefault(); if (e.key === ' ') {
} togglePlay();
if (e.key === '?') { e.preventDefault();
setHelpOpen(true); }
setGotoOpen(false); if (e.key === '?') {
e.preventDefault(); setHelpOpen(true);
setGotoOpen(false);
setSearchOpen(false);
e.preventDefault();
}
} }
if (!inShared && e.getModifierState("Control") && e.key === 'g') { if (!inShared && e.getModifierState("Control") && e.key === 'g') {
setHelpOpen(false); setHelpOpen(false);
setGotoOpen(true); setGotoOpen(true);
setSearchOpen(false);
e.preventDefault();
}
if (!inShared && e.getModifierState("Control") && e.key === 'f') {
setHelpOpen(false);
setGotoOpen(false);
setSearchOpen(true);
e.preventDefault(); e.preventDefault();
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
@ -213,6 +229,7 @@ export const Analyser = (props: AnalyseProps) => {
const buildings = () => parser.getBuildingsAtTick(tick()); const buildings = () => parser.getBuildingsAtTick(tick());
const projectiles = () => parser.getProjectilesAtTick(tick()); const projectiles = () => parser.getProjectilesAtTick(tick());
const kills = parser.getKills(); const kills = parser.getKills();
const events = parser.getEvents();
const playButtonText = () => (playing()) ? '⏸' : '▶️'; const playButtonText = () => (playing()) ? '⏸' : '▶️';
const inShared = session && !session.isOwner(); const inShared = session && !session.isOwner();
const isShared = () => sessionName() !== ''; const isShared = () => sessionName() !== '';
@ -263,7 +280,7 @@ export const Analyser = (props: AnalyseProps) => {
})} })}
disabled={inShared}/> disabled={inShared}/>
</div> </div>
<Modal class="help" isOpen={helpOpen()} onCloseRequest={() => setHelpOpen(false)} <Modal isOpen={helpOpen()} onCloseRequest={() => setHelpOpen(false)}
closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content"> closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content">
<h4>Keyboard Shortcuts</h4> <h4>Keyboard Shortcuts</h4>
<table class="shortcuts"> <table class="shortcuts">
@ -310,7 +327,7 @@ export const Analyser = (props: AnalyseProps) => {
</tbody> </tbody>
</table> </table>
</Modal> </Modal>
<Modal class="goto" isOpen={gotoOpen()} onCloseRequest={() => setGotoOpen(false)} <Modal isOpen={gotoOpen()} onCloseRequest={() => setGotoOpen(false)}
closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content"> closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content">
<h4>Goto Tick</h4> <h4>Goto Tick</h4>
<form use:formSubmit={gotoTickSubmitted} class="goto"> <form use:formSubmit={gotoTickSubmitted} class="goto">
@ -319,6 +336,16 @@ export const Analyser = (props: AnalyseProps) => {
ref={autofocus} autofocus type="text" inputmode="numeric" min={0} max={lastTick - 1}/> ref={autofocus} autofocus type="text" inputmode="numeric" min={0} max={lastTick - 1}/>
</form> </form>
</Modal> </Modal>
<Modal isOpen={searchOpen()} onCloseRequest={() => setSearchOpen(false)}
closeOnOutsideClick={true} overlayClass="modal-overlay" contentClass="modal-content">
<EventSearch
players={players()}
search={search()}
onSearch={setSearch}
events={events}
onSelect={(event: Event) => setTickNow(event.tick)}
></EventSearch>
</Modal>
</div> </div>
); );
} }

View file

@ -1,4 +1,13 @@
import {BuildingState, Kill, ParsedDemo, PlayerState, ProjectileState, ProjectileType, WorldBoundaries} from "./Parser"; import {
BuildingState,
Event,
Kill,
ParsedDemo,
PlayerState,
ProjectileState,
ProjectileType,
WorldBoundaries
} from "./Parser";
function getCacheBuster(): string { function getCacheBuster(): string {
const url = document.querySelector('script[src*="viewer"]').attributes.src.value; const url = document.querySelector('script[src*="viewer"]').attributes.src.value;
@ -33,7 +42,18 @@ export class AsyncParser {
const cachedData: ParsedDemo = event.data.demo; const cachedData: ParsedDemo = event.data.demo;
console.log(`packed data: ${(cachedData.data.length / (1024 * 1024)).toFixed(1)}MB`); console.log(`packed data: ${(cachedData.data.length / (1024 * 1024)).toFixed(1)}MB`);
this.world = cachedData.world; this.world = cachedData.world;
this.demo = new ParsedDemo(cachedData.playerCount, cachedData.buildingCount, cachedData.projectileCount, cachedData.world, cachedData.header, cachedData.data, cachedData.kills, cachedData.playerInfo, cachedData.tickCount); this.demo = new ParsedDemo(
cachedData.playerCount,
cachedData.buildingCount,
cachedData.projectileCount,
cachedData.world,
cachedData.header,
cachedData.data,
cachedData.kills,
cachedData.playerInfo,
cachedData.events,
cachedData.tickCount
);
resolve(this.demo); resolve(this.demo);
} }
} }
@ -76,4 +96,8 @@ export class AsyncParser {
getKills(): Kill[] { getKills(): Kill[] {
return this.demo.kills return this.demo.kills
} }
getEvents(): Event[] {
return this.demo.events
}
} }

View file

@ -54,6 +54,14 @@ export async function parseDemo(bytes: Uint8Array, progressCallback: (progress:
let map = get_map(state); let map = get_map(state);
let data = get_data(state); let data = get_data(state);
let events = kills.map((kill: Kill) => {
return {
tick: kill.tick,
type: "kill",
kill
} as Event
});
return new ParsedDemo( return new ParsedDemo(
playerCount, playerCount,
buildingCount, buildingCount,
@ -75,6 +83,7 @@ export async function parseDemo(bytes: Uint8Array, progressCallback: (progress:
data, data,
kills, kills,
playerInfo, playerInfo,
events,
tickCount, tickCount,
); );
} }
@ -206,8 +215,20 @@ export class ParsedDemo {
public readonly tickCount: number; public readonly tickCount: number;
public readonly kills: Kill[]; public readonly kills: Kill[];
public readonly playerInfo: PlayerInfo[]; public readonly playerInfo: PlayerInfo[];
public readonly events: Event[];
constructor(playerCount: number, buildingCount: number, projectileCount: number, world: WorldBoundaries, header: Header, data: Uint8Array, kills: Kill[], playerInfo: PlayerInfo[], tickCount: number) { constructor(
playerCount: number,
buildingCount: number,
projectileCount: number,
world: WorldBoundaries,
header: Header,
data: Uint8Array,
kills: Kill[],
playerInfo: PlayerInfo[],
events: Event[],
tickCount: number
) {
this.playerCount = playerCount; this.playerCount = playerCount;
this.buildingCount = buildingCount; this.buildingCount = buildingCount;
this.projectileCount = projectileCount; this.projectileCount = projectileCount;
@ -216,6 +237,7 @@ export class ParsedDemo {
this.data = data; this.data = data;
this.kills = kills; this.kills = kills;
this.playerInfo = playerInfo; this.playerInfo = playerInfo;
this.events = events;
this.tickCount = tickCount; this.tickCount = tickCount;
} }
@ -309,3 +331,11 @@ function unpackProjectile(bytes: Uint8Array, base: number, world: WorldBoundarie
projectileType, projectileType,
} }
} }
export type KillEvent = {
type: "kill";
tick: number,
kill: Kill,
}
export type Event = KillEvent;

View file

@ -0,0 +1,137 @@
import {Event, Kill, PlayerState} from "./Data/Parser";
import {createEffect, createSignal, For, Show, untrack} from "solid-js";
import {getPlayer, KillIcon, PlayerName, PlayerNames} from "./Render/KillFeed";
import {autofocus} from "@solid-primitives/autofocus";
import {useKeyDownEvent} from "@solid-primitives/keyboard";
export interface EventSearchProps {
events: Event[];
players: PlayerState[];
onSearch: (string) => void;
search: string;
selectedEvent: number;
onSelect: (event: Event) => void;
}
export function EventSearch(props: EventSearchProps) {
const keyEvent = useKeyDownEvent();
const [selected, setSelected] = createSignal<number>(0);
const events = () => filterEvents(props.events, props.players, props.search);
createEffect(() => {
const e = keyEvent();
untrack(() => {
if (e) {
const seekSelected = (offset) => {
const target = Math.max(0, Math.min(selected() + offset, events().length - 1));
setSelected(target);
}
if (e.key === 'ArrowUp') {
seekSelected(-1);
e.preventDefault();
}
if (e.key === 'ArrowDown') {
seekSelected(1);
e.preventDefault();
}
if (e.key === 'Enter') {
props.onSelect(events()[selected()]);
e.preventDefault();
}
}
});
});
return (<div class="event-search">
<input type="text" ref={autofocus} autofocus value={props.search}
onInput={(e) => props.onSearch(e.target.value)}/>
<table class="event-list">
<For each={events()}>{(event, i) =>
<EventView event={event} highlighted={i() == selected()} players={props.players}/>
}</For>
</table>
</div>)
}
interface EventViewProps {
event: Event;
highlighted: boolean,
players: PlayerState[];
}
function EventView(props: EventViewProps) {
let row;
const highlightClass = () => ` ${props.highlighted ? 'highlighted' : ''}`;
createEffect(() => {
if (props.highlighted) {
row.scrollIntoView(false);
}
})
return (
<tr ref={row} class={props.event.type + highlightClass()}>
<Show when={props.event.type == "kill"}>
<KillView kill={props.event.kill} players={props.players}/>
</Show>
</tr>
);
}
interface KillViewProps {
kill: Kill;
players: PlayerState[];
}
function KillView(props: KillViewProps) {
const attacker = getPlayer(props.players, props.kill.attacker);
const assister = getPlayer(props.players, props.kill.assister);
let victim = getPlayer(props.players, props.kill.victim);
return <>
<td class="kill-source">
<PlayerNames players={[attacker, assister]}/>
</td>
<td class="kill-icon">
<KillIcon kill={props.kill}/>
</td>
<td className="kill-target">
<PlayerName player={victim}/>
</td>
</>
}
function filterEvents(events: Event[], players: PlayerState[], query: string): Event[] {
if (query === '') {
return events;
}
query = query.toLowerCase();
let filteredEvents = [].concat(events);
let queryParts = query.split(' ').filter(part => part.length > 0);
for (const queryPart of queryParts) {
const playersForPart = findPlayers(players, queryPart);
filteredEvents = filteredEvents.filter(event => eventMatches(event, playersForPart, queryPart));
}
return filteredEvents;
}
function findPlayers(players: PlayerState[], queryPart: string): number[] {
return players.flatMap(player => {
if (player.info.name.toLowerCase().includes(queryPart)) {
return [player.info.userId]
} else {
return [];
}
})
}
function eventMatches(event: Event, matchedPlayers: number[], queryPart: string): boolean {
if (event.type === "kill") {
const kill = event.kill;
return matchedPlayers.includes(kill.attacker) ||
matchedPlayers.includes(kill.assister) ||
matchedPlayers.includes(kill.victim);
} else {
return false;
}
}

View file

@ -1,68 +1,89 @@
import {Kill, PlayerState} from "../Data/Parser"; import {Kill, PlayerState} from "../Data/Parser";
import {killAlias} from "./killAlias"; import {killAlias} from "./killAlias";
import {For, Show} from "solid-js";
export interface KillFeedProps { export interface KillFeedProps {
kills: Kill[], kills: Kill[],
tick: number; tick: number;
players: PlayerState[]; players: PlayerState[];
} }
export function KillFeed(props: KillFeedProps) { export function KillFeed(props: KillFeedProps) {
const {kills} = props; const {kills} = props;
const relevantKills = () => kills.filter(kill => kill.tick <= props.tick && kill.tick >= (props.tick - 30 * 10)); const relevantKills = () => kills.filter(kill => kill.tick <= props.tick && kill.tick >= (props.tick - 30 * 10));
return <div class="killfeed"> return <ul class="killfeed">
<For each={relevantKills()}>{(kill) => <For each={relevantKills()}>{(kill) =>
<KillFeedItem kill={kill} players={props.players}/> <KillFeedItem kill={kill} players={props.players}/>
}</For> }</For>
</div> </ul>
} }
const teamMap = { const teamMap = {
0: 'unknown', 0: 'unknown',
2: 'red', 2: 'red',
3: 'blue' 3: 'blue'
}; };
export function KillFeedItem({kill, players}: { kill: Kill, players: PlayerState[] }) { interface KillFeedItemProps {
const alias = killAlias[kill.weapon] ? killAlias[kill.weapon] : kill.weapon; kill: Kill;
const attacker = getPlayer(players, kill.attacker); players: PlayerState[];
const assister = getPlayer(players, kill.assister);
let victim = getPlayer(players, kill.victim);
let killIcon;
try {
killIcon = `/images/kill_icons/${alias}.png`;
} catch (e) {
console.log(alias);
killIcon = `/images/kill_icons/skull.png`;
}
if (!victim) {
victim = {
team: 0,
info: {
name: 'Missing player'
}
};
}
return <div class="kill">
{(attacker && kill.attacker !== kill.victim) ?
<span class={"player " + teamMap[attacker.team]}>
{attacker.info.name}
</span> : ''}
{(assister && kill.assister !== kill.victim) ?
<span class={teamMap[assister.team]}></span> : ''}
{(assister && kill.assister !== kill.victim) ?
(<span class={"player " + teamMap[assister.team]}>
{assister.info.name}
</span>) : ''}
<img src={killIcon} class={`kill-icon ${kill.weapon}`}/>
<span class={"player " + teamMap[victim.team]}>
{victim.info.name}
</span>
</div>
} }
function getPlayer(players: PlayerState[], entityId: number): PlayerState { export function KillFeedItem(props: KillFeedItemProps) {
return players.find(player => player.info.userId == entityId); const attacker = getPlayer(props.players, props.kill.attacker);
const assister = getPlayer(props.players, props.kill.assister);
let victim = getPlayer(props.players, props.kill.victim);
return <li class="kill">
<PlayerNames players={[attacker, assister]}/>
<KillIcon kill={props.kill}/>
<PlayerName player={victim}/>
</li>
}
interface KillIconProps {
kill: Kill;
}
export function KillIcon(props: KillIconProps) {
const alias = killAlias[props.kill.weapon] ? killAlias[props.kill.weapon] : props.kill.weapon;
let killIcon;
try {
killIcon = `/images/kill_icons/${alias}.png`;
} catch (e) {
console.log(alias);
killIcon = `/images/kill_icons/skull.png`;
}
return <img src={killIcon} class={`kill-icon ${props.kill.weapon}`}/>
}
interface PlayerNameProps {
player: PlayerState | null
}
export function PlayerName(props: PlayerNameProps) {
return <Show when={props.player}>
<span className={"player " + teamMap[props.player.team]}>
{props.player.info.name}
</span>
</Show>
}
interface PlayerNamesProps {
players: (PlayerState | null)[]
}
export function PlayerNames(props: PlayerNamesProps) {
return <For each={props.players}>{(player, i) => <>
<Show when={i() > 0 && player}>
<span className={teamMap[player.team]}>+</span>
</Show>
<PlayerName player={player}/>
</>}</For>
}
export function getPlayer(players: PlayerState[], entityId: number): PlayerState | null {
return players.find(player => player.info.userId == entityId);
} }

View file

@ -1,38 +1,39 @@
export const killAlias = { export const killAlias = {
'world': 'skull', 'world': 'skull',
'player': 'skull', 'player': 'skull',
'telefrag': 'skull', 'telefrag': 'skull',
'shotgun_pyro': 'shotgun', 'shotgun_pyro': 'shotgun',
'tf_projectile_pipe_remote': 'stickybomb_launcher', 'tf_projectile_pipe_remote': 'stickybomb_launcher',
'the_classic': 'classic', 'the_classic': 'classic',
'tf_projectile_arrow': 'huntsman', 'tf_projectile_arrow': 'huntsman',
'club': 'kukri', 'club': 'kukri',
'shotgun_primary': 'shotgun', 'shotgun_primary': 'shotgun',
'shotgun_soldier': 'shotgun', 'shotgun_soldier': 'shotgun',
'shotgun_hwg': 'shotgun', 'shotgun_hwg': 'shotgun',
'pickaxe': 'escape_plan', 'pickaxe': 'escape_plan',
'tf_projectile_pipe': 'grenade_launcher', 'tf_projectile_pipe': 'grenade_launcher',
'obj_sentrygun': 'sentrygun1', 'obj_sentrygun': 'sentrygun1',
'steel_fists': 'fists_of_steel', 'steel_fists': 'fists_of_steel',
'tf_projectile_rocket': 'rocket_launcher', 'tf_projectile_rocket': 'rocket_launcher',
'obj_sentrygun3': 'sentrygun3', 'obj_sentrygun3': 'sentrygun3',
'obj_sentrygun2': 'sentrygun2', 'obj_sentrygun2': 'sentrygun2',
'worldspawn': 'skull', 'worldspawn': 'skull',
'nonnonviolent_protest': 'conscientious_objector', 'nonnonviolent_protest': 'conscientious_objector',
'deflect_promode': 'deflect_rocket', 'deflect_promode': 'deflect_rocket',
'trigger_hurt': 'bleed', 'trigger_hurt': 'bleed',
'quake_rl': 'original', 'quake_rl': 'original',
'wrangler_kill': 'wrangler', 'wrangler_kill': 'wrangler',
'obj_minisentry': 'minisentry', 'obj_minisentry': 'minisentry',
'pistol_scout': 'pistol', 'pistol_scout': 'pistol',
'bleed_kill': 'bleed', 'bleed_kill': 'bleed',
'maxgun': 'lugermorph', 'maxgun': 'lugermorph',
'rocketlauncher_directhit': 'direct_hit', 'rocketlauncher_directhit': 'direct_hit',
'frontier_kill': 'frontier_justice', 'frontier_kill': 'frontier_justice',
'robot_arm_kill': 'gunslinger', 'robot_arm_kill': 'gunslinger',
'wrench_jag': 'jag', 'wrench_jag': 'jag',
'loose_cannon_explosion': 'loose_cannon', 'loose_cannon_explosion': 'loose_cannon',
'samrevolver': 'big_kill', 'samrevolver': 'big_kill',
'long_heatmaker': 'huo-long_heater', 'long_heatmaker': 'huo-long_heater',
'pep_pistol': 'pistol' 'pep_pistol': 'pistol',
'ai_flamethrower': 'flamethrower',
}; };

View file

@ -7,6 +7,7 @@
@import 'viewer/Player.css'; @import 'viewer/Player.css';
@import 'viewer/PlayerSpec.css'; @import 'viewer/PlayerSpec.css';
@import 'viewer/Timeline.css'; @import 'viewer/Timeline.css';
@import 'viewer/EventSearch.css';
progress { progress {
width: 100%; width: 100%;

View file

@ -0,0 +1,55 @@
div.event-search {
width: 700px;
input[type="text"] {
width: 100%;
margin-bottom: 10px;
}
}
table.event-list {
width: 100%;
border-collapse: collapse;
line-height: 34px;
table-layout: fixed;
tr.highlighted {
background: #3a3a3a;
}
td {
vertical-align: top;
padding: 0 3px;
}
.kill {
& .red {
color: #a75d50;
}
& .blue {
color: #5b818f;
}
td {
width: 45%;
}
td.kill-icon {
width: 60px;
img {
max-width: 60px;
max-height: 25px;
height: auto;
width: auto;
}
text-align: center;
}
td.kill-source {
text-align: right;
}
}
}